Stateful JWTs in NestJS: When Stateless Tokens Aren’t Enough
Suman S.
Jan 15, 2026
JWT-based authentication is everywhere — but most implementations quietly ignore a critical operational problem: revocation.
In a typical stateless JWT setup, once a token is issued, it remains valid until it expires. Logging out doesn’t truly invalidate it. Disabling a compromised account doesn’t immediately cut access. And managing sessions across multiple devices becomes awkward at best.
For many real-world systems — internal tools, B2B platforms, security-sensitive APIs — this tradeoff is unacceptable.
In this article, we’ll build a stateful JWT authentication system in NestJS. We’ll keep the ergonomic benefits of JWTs while restoring server-side control by persisting token metadata and validating every request against it.
Authentication vs Authorization (Why the Distinction Matters)
Although they’re often implemented together, authentication and authorization solve different problems — and treating them separately leads to cleaner, more secure systems.
Authentication — Who are you?
- Verifies user identity
- Uses credentials (email + password)
-
Returns a trusted
Userobject -
Implemented with a custom
LocalStrategy
Authorization — Is this request still allowed?
- Verifies the JWT signature
- Checks whether the token is still valid on the server
- Rejects revoked or expired sessions immediately
- Implemented with a custom JwtStrategy
This separation is the foundation of a stateful JWT design.
Prerequisites
Before diving in, you should have:
- A working NestJS application
-
Passport and
@nestjs/jwtconfigured - TypeORM set up with a database connection
If you’re starting fresh:
nest new auth-demo
User Entity
Since tokens will be tied to users, we need a basic User entity with a relationship to issued access tokens.
// src/users/user.entity.ts
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { OAuthAccessToken } from '../auth/entities/oauth-access-token.entity';
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({ unique: true })
email: string;
@Column()
password: string;
@OneToMany(() => OAuthAccessToken, token => token.user)
accessTokens: OAuthAccessToken[ ];
}
The Core Idea: Treat JWTs as Session Identifiers
Instead of relying solely on the JWT itself, we treat it as a pointer to a server-side session record.
The JWT contains:
- The user ID
-
A unique
tokenId
The database contains:
- Token lifecycle metadata
- Expiration information
- Revocation state
This allows us to invalidate access instantly — without waiting for token expiration.
Token Entity
// src/auth/entities/oauth-access-token.entity.ts
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { User } from '../../users/user.entity';
@Entity('oauth_access_token')
export class OAuthAccessToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
tokenId: string;
@Column()
expiresAt: Date;
@Column({ nullable: true})
revokedAt: Date;
@Column({default: false})
revoked: boolean;
@ManyToOne(() => User,user => user.accessTokens, {onDelete: 'CASCADE'})
user: User;
}
Key design choices:
-
uuidensures token IDs are unguessable -
revokedAtenables auditing and forensic analysis -
CASCADEdeletion prevents orphaned sessions
Authentication: Local Strategy and Login Flow
Authentication is the entry point. We customize Passport’s local strategy to authenticate using email and password.
Local Strategy
// src/auth/strategies/local.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from '../auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super({ usernameField: 'email', passwordField: 'password', });
}
async validate(email: string, password: string) {
const user = await this.authService.validateUser(email, password);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
Generating a Stateful JWT
At login time, we:
-
1. Generate a unique
tokenId - 2. Persist it with expiration metadata
-
3. Embed the
tokenIdinside the JWT payload
// src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { randomUUID } from 'crypto';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OAuthAccessToken } from './entities/oauth-access-token.entity';
import { User } from '../users/user.entity';
@Injectable()
export class AuthService {
constructor(private jwtService: JwtService, @InjectRepository(OAuthAccessToken) private tokenRepo: Repository<OAuthAccessToken>) {}
async login(user: User) {
const tokenId = randomUUID();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 180);
await this.tokenRepo.save({ tokenId, user, expiresAt, });
const payload = { sub: user.id, tokenId, };
return { accessToken: await this.jwtService.signAsync(payload), };
}
}
Authorization: Stateful JWT Validation
This is where the approach differs fundamentally from standard JWT authentication.
A valid signature is necessary — but not sufficient.
JWT Strategy
// src/auth/strategies/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { AuthService } from '../auth.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(
configService: ConfigService,
private authService: AuthService,
) {
super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKey: configService.get('passport.secret'), });
}
async validate(payload: any) {
const isValid = await this.authService.validateToken(payload.tokenId);
if (!isValid) {
throw new UnauthorizedException('Token revoked');
}
return { id: payload.sub };
}
}
Every request checks:
- Token signature
- Token existence
- Revocation state
If any check fails, access is denied immediately.
In high-throughput systems, this lookup can be cached (e.g., Redis) while preserving revocation guarantees.
Guards and Developer Ergonomics
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
Optional helper decorator:
export const CurrentUser = createParamDecorator(
(_, ctx: ExecutionContext) => ctx.switchToHttp().getRequest().user,
);
This keeps controllers clean and focused on business logic.
When Should You Use Stateful JWTs?
This approach isn’t free — you’re reintroducing server-side state. But it’s worth it when:
- Logout must invalidate access immediately
- Compromised tokens must be revoked instantly
- Multi-device session management matters
- Security outweighs horizontal scaling simplicity
For purely public or low-risk APIs, stateless JWTs may still be sufficient.
Final Thoughts
Stateful JWT authentication strikes a pragmatic balance between modern token-based auth and traditional session control. By separating authentication from authorization and treating JWTs as session identifiers — not authority themselves — you gain operational leverage without sacrificing developer experience.