Stateful JWTs in NestJS: When Stateless Tokens Aren’t Enough

Stateful JWTs in NestJS: When Stateless Tokens Aren’t Enough

Written by
Written by

Suman S.

Post Date
Post Date

Jan 15, 2026

shares

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?

Authorization — Is this request still allowed?

This separation is the foundation of a stateful JWT design.

Prerequisites

Before diving in, you should have:

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 database contains:

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:

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:


    // 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:

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:

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.