← Back to the section

When a client sends a request with a token, the service must make sure: this token is genuine, not expired, and issued by exactly the authorization center we trust. One missed step — and an attacker forges a token, declares themselves an administrator and does whatever they want.

In NestJS this task is solved by a ready-made pair of libraries: passport-jwt + jwks-rsa. All the cryptographic code is already written and debugged — all that's left for you is to assemble the configuration correctly.

What a JWT is and why it must be verified carefully

A JWT (JSON Web Token) is a string of three parts: a header, a body with data about the user, and a digital signature. The signature is created by the authorization center (Identity Provider, IdP) with its private key. Your service verifies the signature with the public key — if the check passes, the token is not forged.

The problem is that "verify the token" is not a single step but several:

  • Verify the signature — the token is unchanged and signed with the right key.
  • Verify exp — the token is not expired.
  • Verify iss — the token was issued by exactly our IdP, not someone else's.
  • Verify aud — the token is intended for exactly this service.

Skipping any of these steps means opening a vulnerability. That is exactly why writing the token check yourself is dangerous: it's easy to forget one thing.

JwtStrategy: how it works in NestJS

NestJS uses the Passport library for authentication. For JWT there is a ready strategy — passport-jwt. And for loading public keys from the IdP — jwks-rsa.

Here is what the standard configuration looks like:

// adapters/in/http/security/jwt.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa';
import { AppConfig } from '../../../../config/app.config';
import { Principal } from '../../../../core/domain/principal';
import { JwtClaims } from './jwt-claims';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(config: AppConfig) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      algorithms: ['RS256'],
      audience: config.auth.audience,
      issuer: config.auth.issuer,
      secretOrKeyProvider: passportJwtSecret({
        jwksUri: config.auth.jwksUri,
        cache: true,
        cacheMaxAge: 300_000,   // cache for 5 minutes
        rateLimit: true,
      }),
    });
  }

  validate(claims: JwtClaims): Principal {
    // we only get here if the signature, exp, iss, aud have already been verified
    return {
      sub: claims.sub,
      roles: extractRoles(claims),
    };
  }
}

What happens on each request:

  1. passport-jwt extracts the token from the Authorization: Bearer <token> header.
  2. jwks-rsa loads the IdP's public keys from jwksUri (from the cache, if they are already there).
  3. The signature, exp, iss, aud are verified.
  4. Only if everything is in order — your validate() method is called.

The validate() method receives already-verified data. There's no need to duplicate the signature or expiry check in it.

Why you must not write the check by hand

Sometimes developers write their own Guard with jwt.decode(). It looks simple, but several critical mistakes are hidden in it:

// WRONG — don't do this
const token = req.headers.authorization?.split(' ')[1];
const decoded = jwt.decode(token);   // decode does not verify the signature!
if (decoded.exp < Date.now() / 1000) return false;
req.user = decoded;
return true;

What's broken here:

  • jwt.decode() only parses the token but does not verify the signature. An attacker signs a token with their own key — and it passes.
  • No iss check — a token from another IdP is accepted.
  • No aud check — another service's token passes.
  • No account for clock inaccuracy (clock skew) — for clients whose clocks are ±30 seconds off, exp breaks.
  • The alg: none vulnerability — a token with no signature at all is accepted.

Each of these mistakes is a serious vulnerability. passport-jwt + jwks-rsa have already solved all these problems in code that has passed a security audit.

The public key cache

The IdP publishes public keys in JWK Set format at a standard address. Fetching the keys straight from the IdP on every token check is a bad idea: it's extra latency on every request and load on the IdP.

jwks-rsa caches the keys automatically. The cacheMaxAge: 300_000 setting keeps them in the cache for five minutes. If the IdP rotated its keys but the cache still has the old ones — jwks-rsa notices an unknown kid in the token and requests fresh keys automatically. rateLimit: true protects against the situation where an attacker deliberately sends requests with unknown kids to overload the IdP.

There's no need to keep the public key in an environment variable or a config file — jwks-rsa loads it itself by jwksUri. This also means key rotation at the IdP does not require a service restart.

Guards: authentication and authorization are different steps

In NestJS the check happens in two layers:

JwtAuthGuard — verifies that the token is genuine. If not — returns 401.
RolesGuard — verifies that the user has the required role. If not — returns 403.

The order matters: authentication first, authorization after.

// adapters/in/http/security/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private readonly reflector: Reflector) {
    super();
  }

  canActivate(ctx: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      ctx.getHandler(),
      ctx.getClass(),
    ]);
    if (isPublic) return true;
    return super.canActivate(ctx);
  }
}
// adapters/in/http/security/roles.guard.ts
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(ctx: ExecutionContext): boolean {
    const required = this.reflector.get(Roles, ctx.getHandler());
    if (!required) throw new ForbiddenException();
    const { user } = ctx.switchToHttp().getRequest<{ user: Principal }>();
    if (!required.some((r) => user.roles.includes(r))) {
      throw new ForbiddenException();
    }
    return true;
  }
}

Both Guards are registered globally in app.module.ts:

providers: [
  { provide: APP_GUARD, useClass: JwtAuthGuard },   // 401 — authentication
  { provide: APP_GUARD, useClass: RolesGuard },      // 403 — authorization
],

Public routes — for example, GET /health — are marked with the @Public() decorator. Without it the Guard will require a token on every request.

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

The difference between 401 and 403

This is a common confusion that breaks client applications.

401 Unauthorized — the token is invalid. Either the signature is wrong, or the token is expired, or the Authorization header is missing entirely. The client should refresh the token (through the refresh-token flow) or redirect the user to the login page.

403 Forbidden — the token is valid, the user is known, but they have no rights for this action. Refreshing the token is pointless — the role won't change. The client should show an "access denied" message.

A typical mistake is to override handleRequest and return 403 instead of 401:

// WRONG
handleRequest(err: unknown, user: Principal) {
  if (err || !user) throw new ForbiddenException();   // masks 401 as 403
}

The client sees 403 on an expired token, thinks "no rights", doesn't refresh, and hangs. The correct behavior is to leave JwtAuthGuard as is: it throws UnauthorizedException (401) itself on token problems.

Roles from the JWT token

The user's roles live inside the JWT as claims. Keycloak puts them in realm_access.roles, OAuth2 — in the scope field. Mapping into the Principal object is done once — in the strategy's validate() method:

// adapters/in/http/security/jwt-claims.ts
export interface JwtClaims {
  sub: string;
  realm_access?: { roles: string[] };   // Keycloak
  scope?: string;                        // OAuth2 scope
}

// adapters/in/http/security/extract-roles.ts
const ALLOWED_ROLES = new Set(['customer', 'seller', 'admin', 'system']);

export function extractRoles(claims: JwtClaims): string[] {
  const raw = claims.realm_access?.roles ?? claims.scope?.split(' ') ?? [];
  return raw.filter((r) => ALLOWED_ROLES.has(r));
}

On specific routes the roles are declared through the @Roles decorator:

@Controller('orders')
export class OrderController {
  @Post()
  @Roles(['customer', 'seller'])
  async createOrder(@Body() dto: CreateOrderDto, @Req() req: Request & { user: Principal }) {
    return this.createOrderUseCase.execute(dto, req.user);
  }

  @Delete(':id')
  @Roles(['customer', 'admin'])
  async cancelOrder(@Param('id') id: string, @Req() req: Request & { user: Principal }) {
    return this.cancelOrderUseCase.execute(id, req.user);
  }

  @Get('health')
  @Public()
  healthCheck(): string {
    return 'ok';
  }
}

Each route must explicitly declare either @Roles(...) or @Public(). A route without either decorator will pass authentication but fail on authorization — this is a useful restriction: a forgotten route won't accidentally be left open.

In short

  • A JWT contains a digital signature — it must be verified with the IdP's public key. jwt.decode() does not verify the signature.
  • passport-jwt + jwks-rsa verify the signature, expiry, issuer and audience — all automatically.
  • The IdP's public keys are kept in a cache (cache: true, cacheMaxAge: 300_000) and updated on rotation without a service restart.
  • validate() receives already-verified data — there's no need to duplicate the crypto logic there.
  • JwtAuthGuard gives 401 (invalid token), RolesGuard gives 403 (no rights). You must not confuse them — the client reacts differently to each.
  • Every route is explicitly marked @Roles(...) or @Public().