NestJS 9: Enforcing Least‑Privilege Authentication with Expired Tokens
0 reputation · 12 Sept 2022, 22:07 UTC
0 reputation · 12 Sept 2022, 22:07 UTC
Implement a strategy that guarantees least‑privilege access when a JWT is expired or revoked in a NestJS 9 application.
• NestJS ships only a JwtStrategy that validates expiration but offers no built‑in revocation list.
• RolesGuard grants access if *any* role matches the @Roles decorator, yet it cannot detect whether the token was revoked or simply expired.
• There is no standard configuration to distinguish between expired, revoked, or malformed tokens in AuthGuard’s UnauthorizedException.
What is the optimal revocation mechanism that integrates cleanly with NestJS’s guard pipeline while preserving the least‑privilege model?
33775 reputation · 12 Sept 2022, 22:21 UTC
The cleanest way to enforce least‑privilege when a JWT is expired or revoked is to insert a revocation check before any role evaluation in the guard pipeline. Implement a custom AuthGuard that validates the token, extracts its jti, queries a fast revocation store (Redis), and throws a distinct UnauthorizedException for revoked tokens. Then extend RolesGuard to call the same revocation service before matching roles, guaranteeing that a revoked token never passes role checks.
JwtStrategy; revocation lives in a dedicated service.Token expired vs Token revoked, letting clients react differently.jti claim when signing
JwtModule.register({
secret: process.env.JWT_SECRET,
signOptions: { expiresIn: '15m', jwtid: () => uuidv4() },
});
RevocationService (Redis)
@Injectable()
export class RevocationService {
constructor(@Inject('REDIS_CLIENT') private redis: Redis) {}
async revoke(jti: string, ttlSeconds: number) {
await this.redis.set(`revoked:${jti}`, '1', 'EX', ttlSeconds);
}
async isRevoked(jti: string): Promise {
return (await this.redis.exists(`revoked:${jti}`)) === 1;
}
}
JwtAuthGuard
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private revocation: RevocationService) {
super();
}
async handleRequest(err, user, info) {
if (err || !user) throw err || new UnauthorizedException('Token expired');
const jti = user.jti;
if (await this.revocation.isRevoked(jti)) {
throw new UnauthorizedException('Token revoked');
}
return user;
}
}
RolesGuard
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private revocation: RevocationService) {}
async canActivate(ctx: ExecutionContext): Promise {
const req = ctx.switchToHttp().getRequest();
const user = req.user;
if (!user) return false;
if (await this.revocation.isRevoked(user.jti)) return false;
const required = this.reflector.get('roles', ctx.getHandler());
return required.some(r => user.roles.includes(r));
}
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
@Get('admin')
async adminRoute() { … }
Redis is optimal: O(1) lookup, automatic key expiry matching token TTL, and negligible latency for high‑traffic APIs. If Redis is unavailable, a relational DB with an indexed jti column works but adds latency.
Do your issued JWTs already contain a unique jti claim? If not, you must add it (step 1) before per‑token revocation is possible; otherwise you can only revoke whole user sessions.
jti present.jti in Redis with TTL = token expiry.jti → call again → expect 401 Token revoked.Token expired.Use comments to ask for clarification. Post a solution as an answer.
33,775 reputation · 13 Sept 2022, 09:05 UTC
1. Include a unique jti claim when you sign the token. 2. Override JwtStrategy.validate to query a Redis set keyed by that jti.
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly redis: RedisService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false }); }
async validate(payload: any) {
const revoked = await this.redis.sismember('revoked', payload.jti);
if (revoked) throw new RevokedTokenException();
return payload; // proceed to RolesGuard
}
}
3. Extend RolesGuard to run only after JwtAuthGuard has passed the revocation check. 4. Store revocation entries with a TTL that matches the token’s exp so Redis cleans them automatically.
When a token is revoked, the guard throws RevokedTokenException; when it simply expires, JwtStrategy throws the standard UnauthorizedException. A global filter can translate these into distinct HTTP status codes.