Tips

Building Stateless JWT Authentication in NestJS

A practical guide to access/refresh token pairs, Passport strategies, and guards for production-grade JWT auth in NestJS APIs.

Building Stateless JWT Authentication in NestJS

Most NestJS tutorials stop at @UseGuards(AuthGuard('jwt')) and a happy-path login endpoint. Production systems need more: token rotation, revocation, and a clear boundary between what the client can see and what the server keeps secret. This post walks through a JWT setup that has survived real traffic, not just a demo.

Two tokens, two lifetimes, two jobs

A single long-lived JWT is convenient and dangerous: stealing it once means impersonating the user until it expires, and you cannot revoke a signed token without a server-side check that defeats the purpose of being stateless. The standard fix is a short-lived access token (5-15 minutes, sent on every request) paired with a longer-lived refresh token (days to weeks, sent only to a dedicated /auth/refresh endpoint and stored server-side so it can be revoked).

// auth/auth.service.ts
async login(user: UserEntity) {
  const payload: JwtPayload = { sub: user.id, role: user.role };

  const accessToken = await this.jwtService.signAsync(payload, {
    secret: this.config.get('JWT_ACCESS_SECRET'),
    expiresIn: '15m',
  });

  const refreshToken = await this.jwtService.signAsync(payload, {
    secret: this.config.get('JWT_REFRESH_SECRET'),
    expiresIn: '30d',
  });

  // Store a hash, never the raw token, so a DB leak doesn't leak sessions.
  await this.sessions.save({
    userId: user.id,
    jwtId: payload.sub,
    tokenHash: await argon2.hash(refreshToken),
    expiresAt: addDays(new Date(), 30),
  });

  return { accessToken, refreshToken };
}

Why the refresh token lives in the database

The access token stays fully stateless — any instance can verify it with just the shared secret. The refresh token trades a little bit of that statelessness for control: a row per session means "log out everywhere" is a DELETE, a compromised device can be revoked individually, and a rotated refresh token that gets reused twice is a strong signal of theft.

  • Sign access and refresh tokens with different secrets, so leaking one does not let an attacker mint the other.
  • Put only the minimum claims in the payload: user id and role. Anything else is either stale by the time it is read or a privacy leak.
  • Set expiresIn on both the JWT and, redundantly, check session.expiresAt server-side — defense in depth against clock or library bugs.
  • Rotate the refresh token on every use: issue a new one, invalidate the old row, and treat re-use of an already-rotated token as a breach signal.
  • Never store a raw refresh token; hash it the same way you hash passwords.

Guards and the current-user decorator

A JwtStrategy extending PassportStrategy(Strategy) validates the signature and expiry, then returns whatever validate() resolves to — that becomes request.user. Wrapping it in a custom @CurrentUser() param decorator keeps controllers free of request.user casts scattered everywhere.

// auth/strategies/jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(config: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: config.getOrThrow('JWT_ACCESS_SECRET'),
    });
  }

  async validate(payload: JwtPayload): Promise<JwtPayload> {
    // Keep this cheap: it runs on EVERY authenticated request.
    return { sub: payload.sub, role: payload.role };
  }
}

// common/decorators/current-user.decorator.ts
export const CurrentUser = createParamDecorator(
  (_: unknown, ctx: ExecutionContext): JwtPayload =>
    ctx.switchToHttp().getRequest().user,
);

Keep validate() cheap. It is tempting to hit the database there to attach a fresh user record, but that turns every authenticated request into an extra query. Load the full user only in the handlers that actually need it.

Mistakes that show up in production, not in demos

The most common failure is storing the access token in localStorage and calling it a day — it is readable by any script on the page, which makes a single XSS bug a full account takeover. An httpOnly, SameSite=strict cookie for the refresh token, with the access token kept in memory on the client, closes that hole at the cost of a slightly more involved refresh flow. The second common failure is trusting role claims baked into an old access token after a role change; a 15-minute window is usually an acceptable trade-off, but if instant revocation matters, keep a short-lived denylist keyed by jwtId.

Testing the flow end to end

A guard is only proven correct by a test that calls the real HTTP layer. Spin up the app with Test.createTestingModule, call /auth/login, assert the shape of the response, then use the returned access token on a protected route and confirm both the 200 with a valid token and the 401 without one. Cover refresh-token rotation explicitly: reusing a rotated token must fail, and a legitimate rotation must issue a token that itself works.

Logging out of every device, not just the current one

A single "log out" button that only clears the current browser's cookie leaves every other session — a phone, a forgotten library computer, a device that was stolen — still holding a valid refresh token. Because refresh sessions are rows in a table keyed by user id, "log out everywhere" is a query, not a cryptographic operation: delete every session row for that user, and the next refresh attempt from any device fails with a 401 instead of silently succeeding.

// auth/auth.service.ts
async logoutAllDevices(userId: string): Promise<void> {
  await this.sessions.delete({ userId });
}

async listActiveSessions(userId: string): Promise<SessionSummaryDto[]> {
  const sessions = await this.sessions.find({
    where: { userId, revokedAt: IsNull() },
    order: { lastSeenAt: 'DESC' },
  });

  return sessions.map((s) => ({
    id: s.id,
    deviceName: s.deviceName,
    lastSeenAt: s.lastSeenAt,
    isCurrent: s.jwtId === currentJwtId,
  }));
}

Surfacing that session list in the account settings page — with a "sign out" button per row — turns an abstract security feature into something a user can actually act on the moment they suspect a device was compromised, without waiting on support.

  • A password change should always revoke every existing session except, optionally, the one that just performed the change.
  • Store lastSeenAt and update it on refresh, not on every request — updating it on every request turns a read-heavy endpoint into a write on every call.
  • Show enough device information (browser, OS, approximate location from IP) that a user can actually tell which row is unfamiliar, not just an opaque session id.
  • A "log out everywhere" action is itself worth rate limiting — it is a legitimate target for denial-of-service against a single account.

Where the access token actually lives on the client

Two options survive contact with a real frontend: keep the access token only in memory (a module-level variable or a state store, never localStorage) and re-fetch it on page reload via the httpOnly refresh cookie, or skip a client-visible access token entirely and let every request ride on the httpOnly cookie directly, verified server-side on each call. The first trades a brief "am I logged in" flash on reload for a smaller attack surface; the second removes that flash but ties the frontend more tightly to same-site cookie behavior.

// On app boot, before rendering anything that needs auth:
async function bootstrapSession(): Promise<void> {
  try {
    const { accessToken } = await api.post('/auth/refresh'); // sends httpOnly cookie
    setAccessToken(accessToken); // in-memory only, never persisted
  } catch {
    setAccessToken(null); // not logged in, or refresh cookie expired
  }
}

Whichever shape is chosen, the refresh cookie itself needs SameSite=strict (or lax if a cross-site redirect flow requires it), Secure in any environment served over HTTPS, and a Path scoped to the refresh endpoint alone — a cookie sent on every single request, including ones that never touch auth, is unnecessary exposure for no benefit.

  • A page reload always has a brief window where the client does not yet know if it is authenticated — design the loading state for that window instead of assuming synchronous auth state.
  • CSRF protection is only necessary for the cookie-based paths; a bearer token read from an Authorization header is not automatically submitted by the browser, so it does not need the same defense.
  • Rotating the refresh cookie's signing secret invalidates every session at once — a deliberate, blunt tool worth keeping for a full incident response, separate from per-user revocation.

Picking token lifetimes for a real product, not a demo

A 15-minute access token and a 30-day refresh token are reasonable defaults, not a law of physics — the right numbers depend on what a stolen token actually lets an attacker do. A read-only browsing session can afford a longer access token lifetime than a session that can trigger a payment or change an email address; splitting "sensitive" actions behind a fresh re-authentication check, independent of the general token lifetime, is a cheaper fix than shortening every token's lifetime to protect the rare high-stakes action.

It is also worth deciding up front what happens when a refresh token is presented after the user account itself has been suspended or deleted — the token can still be cryptographically valid and unexpired while the account it belongs to no longer should be usable, which is exactly why the refresh flow checks the live user.status in the database on every use rather than trusting the token alone.

Conclusion

JWT auth in NestJS is simple to start and easy to get subtly wrong. Two tokens with two lifetimes, a hashed and revocable refresh session, a thin validate() step, and httpOnly cookie storage on the client cover the failure modes that actually show up once real users — and real attackers — start hitting the API.

Member discussion

Share your thoughts with the ToshStack community.

Join the discussion

Become a member of ToshStack to start commenting.

Already a member? Sign in