JWT Authentication in NestJS With Refresh Tokens — Production Implementation

I shipped a single-token JWT auth system once. It worked until I realized that if the token was stolen, I had zero options — no revoke, no rotate, just watch and wait for the 24-hour expiry while someone else had a valid session. (The ticket that sent me down that path said "add authentication." The ticket is always lying about scope.)
Every NestJS tutorial shows you how to sign a JWT. None of them show you what happens when the token inevitably leaks. The two-token pattern — a short-lived access token paired with a long-lived, revocable refresh token — is the standard fix. But most tutorials stop at "here's how to generate two JWTs," which is like saying "here's how to make toast" and walking away before the kitchen catches fire.
The average data breach costs $4.88 million (IBM, 2024), and stolen credentials are the initial action in 24% of breaches (Verizon DBIR 2024). Auth is not the feature you want to get 80% right.
This is a complete NestJS JWT refresh token setup for production: database-backed refresh tokens, httpOnly cookie storage, token rotation, reuse detection, a Redis blacklist for immediate access token invalidation, and logout from a single device or all devices.
How the NestJS JWT Refresh Token Flow Works
Before code, the mechanism. Two tokens, two different properties:
Access token. Short-lived (15 minutes), stateless, sent on every API call. The server verifies the signature without a database lookup — fast, but impossible to revoke once issued. If stolen, it's usable until it expires.
Refresh token. Long-lived (30 days), stored in the database, never sent to regular API routes. Used only to obtain new access tokens. Revocable at any time by deleting its database record.
The flow looks like this:
- User logs in with credentials → server creates an access token (15 min) and a refresh token (30 days), stores the refresh token hash in the database, sends both to the client
- Client sends the access token in the
Authorizationheader on every request - When the access token expires (HTTP 401), the client calls
POST /auth/refreshwith the refresh token - Server validates the refresh token, checks the database, issues a new access token and a new refresh token (rotation), invalidates the old refresh token
- If the same refresh token is submitted twice, the server detects reuse and invalidates the entire token family
- On logout, the server deletes the refresh token from the database and adds the access token to a Redis blacklist
This is not optional complexity. A single long-lived JWT is the auth equivalent of writing your password on a sticky note.
Database Schema for Refresh Token Storage
The refresh token itself is stateless (it's a JWT), but we need a database record to track it, enable revocation, and identify which device issued it.
1CREATE TABLE "refresh_tokens" (
2 "id" uuid NOT NULL DEFAULT gen_random_uuid(),
3 "user_id" uuid NOT NULL REFERENCES "users"("id"),
4 "token_hash" text NOT NULL, -- sha256 of the refresh token
5 "family_id" uuid NOT NULL, -- groups tokens from same login session
6 "device_name" text, -- "Chrome on macOS", "iPhone Safari"
7 "device_ip" inet,
8 "expires_at" timestamptz NOT NULL,
9 "created_at" timestamptz NOT NULL DEFAULT now(),
10 "revoked_at" timestamptz, -- null until revoked
11 CONSTRAINT "PK_refresh_tokens" PRIMARY KEY ("id")
12);
13
14CREATE INDEX "idx_refresh_tokens_user_id" ON "refresh_tokens" ("user_id");
15CREATE INDEX "idx_refresh_tokens_family_id" ON "refresh_tokens" ("family_id");Key design decisions here:
- We store a hash, not the raw token. If the database is breached, the refresh tokens cannot be used. SHA-256 is fine here because the token itself is a high-entropy random value — no salting needed.
family_idgroups tokens by login session. When a token is rotated, the new token shares the samefamily_id. Reuse detection works at the family level.device_nameanddevice_ipenable the "show active sessions" feature every enterprise buyer asks for in a security review.revoked_atsupports soft revocation without data loss, useful for audit trails.
In TypeORM:
1// refresh-token.entity.ts
2import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
3import { User } from './user.entity';
4
5@Entity('refresh_tokens')
6export class RefreshToken {
7 @PrimaryGeneratedColumn('uuid')
8 id: string;
9
10 @Column({ name: 'user_id' })
11 @Index()
12 userId: string;
13
14 @ManyToOne(() => User)
15 @JoinColumn({ name: 'user_id' })
16 user: User;
17
18 @Column({ name: 'token_hash' })
19 tokenHash: string;
20
21 @Column({ name: 'family_id' })
22 @Index()
23 familyId: string;
24
25 @Column({ name: 'device_name', nullable: true })
26 deviceName: string;
27
28 @Column({ name: 'device_ip', type: 'inet', nullable: true })
29 deviceIp: string;
30
31 @Column({ name: 'expires_at', type: 'timestamptz' })
32 expiresAt: Date;
33
34 @Column({ name: 'created_at', type: 'timestamptz', default: () => 'now()' })
35 createdAt: Date;
36
37 @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
38 revokedAt: Date;
39}
The NestJS Auth Module Setup
Install the dependencies:
1npm install @nestjs/jwt @nestjs/passport passport passport-jwt cookie-parser
2npm install -D @types/passport-jwt @types/cookie-parser
3npm install bcrypt @types/bcrypt # password hashing
4npm install ioredis @types/ioredis # Redis blacklistA production setup needs two JWT registrations — one for short-lived access tokens and one for long-lived refresh tokens. Set up the module:
1// auth.module.ts
2import { Module } from '@nestjs/common';
3import { JwtModule } from '@nestjs/jwt';
4import { PassportModule } from '@nestjs/passport';
5import { ConfigModule, ConfigService } from '@nestjs/config';
6import { TypeOrmModule } from '@nestjs/typeorm';
7import { AuthController } from './auth.controller';
8import { AuthService } from './auth.service';
9import { RefreshToken } from './refresh-token.entity';
10import { User } from '../users/user.entity';
11import { JwtStrategy } from './jwt.strategy';
12import { JwtRefreshStrategy } from './jwt-refresh.strategy';
13import { RedisModule } from '../redis/redis.module';
14
15@Module({
16 imports: [
17 PassportModule.register({ defaultStrategy: 'jwt' }),
18 JwtModule.registerAsync({
19 imports: [ConfigModule],
20 inject: [ConfigService],
21 useFactory: (config: ConfigService) => ({
22 secret: config.get<string>('JWT_ACCESS_SECRET'),
23 signOptions: { expiresIn: '15m' },
24 }),
25 }),
26 TypeOrmModule.forFeature([RefreshToken, User]),
27 RedisModule,
28 ],
29 controllers: [AuthController],
30 providers: [AuthService, JwtStrategy, JwtRefreshStrategy],
31 exports: [AuthService],
32})
33export class AuthModule {}A critical detail: use two separate secrets:
1JWT_ACCESS_SECRET=your-access-secret-here
2JWT_REFRESH_SECRET=your-refresh-secret-hereUse different secrets for access and refresh tokens. If one leaks, the other is still safe.
Generating the Two Tokens
The token generation method produces both tokens and persists the refresh token:
1// auth.service.ts
2import { Injectable, UnauthorizedException } from '@nestjs/common';
3import { JwtService } from '@nestjs/jwt';
4import { ConfigService } from '@nestjs/config';
5import { InjectRepository } from '@nestjs/typeorm';
6import { Repository } from 'typeorm';
7import * as bcrypt from 'bcrypt';
8import * as crypto from 'crypto';
9import { v4 as uuidv4 } from 'uuid';
10import { RefreshToken } from './refresh-token.entity';
11import { User } from '../users/user.entity';
12
13@Injectable()
14export class AuthService {
15 constructor(
16 private jwtService: JwtService,
17 private configService: ConfigService,
18 @InjectRepository(RefreshToken)
19 private refreshTokenRepo: Repository<RefreshToken>,
20 @InjectRepository(User)
21 private userRepo: Repository<User>,
22 ) {}
23
24 async login(email: string, password: string, deviceName?: string, ip?: string) {
25 const user = await this.userRepo.findOneBy({ email });
26 if (!user) throw new UnauthorizedException('Invalid credentials');
27
28 const valid = await bcrypt.compare(password, user.passwordHash);
29 if (!valid) throw new UnauthorizedException('Invalid credentials');
30
31 return this.issueTokens(user, deviceName, ip);
32 }
33
34 private async issueTokens(user: User, deviceName?: string, ip?: string) {
35 const payload = { sub: user.id, email: user.email };
36 const familyId = uuidv4();
37
38 // Access token: 15 minutes, stateless
39 const accessToken = this.jwtService.sign(payload);
40
41 // Refresh token: 30 days, separate secret
42 const refreshToken = this.jwtService.sign(payload, {
43 secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
44 expiresIn: '30d',
45 });
46
47 // Store hash of refresh token in database
48 const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
49
50 await this.refreshTokenRepo.insert({
51 userId: user.id,
52 tokenHash,
53 familyId,
54 deviceName: deviceName ?? null,
55 deviceIp: ip ? this.parseIp(ip) : null,
56 expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
57 });
58
59 return { accessToken, refreshToken, familyId };
60 }
61
62 private parseIp(ip: string): any {
63 // TypeORM inet type accepts string; cast appropriately for your driver
64 return ip;
65 }
66}The access token uses the default JwtModule config (15 minute expiry). The refresh token uses JWT_REFRESH_SECRET with a 30-day expiry. The raw refresh token is never stored — only its SHA-256 hash.
Storing the Refresh Token in an httpOnly Cookie
The refresh token should never be accessible to JavaScript. Send it as an httpOnly cookie scoped to the refresh endpoint:
1// auth.controller.ts
2import { Controller, Post, Body, Res, Req, UseGuards, HttpCode, UnauthorizedException } from '@nestjs/common';
3import { InjectRepository } from '@nestjs/typeorm';
4import { Repository } from 'typeorm';
5import { JwtService } from '@nestjs/jwt';
6import { ConfigService } from '@nestjs/config';
7import { AuthGuard } from '@nestjs/passport';
8import { Response, Request } from 'express';
9import * as crypto from 'crypto';
10import { AuthService } from './auth.service';
11import { RefreshToken } from './entities/refresh-token.entity';
12import { TokenBlacklistService } from './token-blacklist.service';
13import { LocalAuthGuard } from './local-auth.guard';
14import { JwtRefreshAuthGuard } from './jwt-refresh-auth.guard';
15
16// login, refresh, logout, and logout-all all live on this controller, which
17// injects the dependencies each method needs.
18@Controller('auth')
19export class AuthController {
20 constructor(
21 private readonly authService: AuthService,
22 @InjectRepository(RefreshToken)
23 private readonly refreshTokenRepo: Repository<RefreshToken>,
24 private readonly jwtService: JwtService,
25 private readonly configService: ConfigService,
26 private readonly blacklistService: TokenBlacklistService,
27 ) {}
28
29 @UseGuards(LocalAuthGuard)
30 @Post('login')
31 @HttpCode(200)
32 async login(
33 @Body('email') email: string,
34 @Body('password') password: string,
35 @Body('device_name') deviceName: string | undefined,
36 @Req() req: Request,
37 @Res({ passthrough: true }) res: Response,
38 ) {
39 const ip = req.ip;
40 const result = await this.authService.login(email, password, deviceName, ip);
41
42 // Access token in the response body
43 // Refresh token in an httpOnly cookie
44 res.cookie('refresh_token', result.refreshToken, {
45 httpOnly: true,
46 secure: process.env.NODE_ENV === 'production',
47 sameSite: 'strict',
48 path: '/auth/refresh',
49 maxAge: 30 * 24 * 60 * 60 * 1000,
50 });
51
52 return {
53 accessToken: result.accessToken,
54 familyId: result.familyId,
55 };
56 }
57}Three important cookie settings:
httpOnly: true— JavaScript cannot read the cookie. XSS vulnerabilities cannot steal the refresh token.path: '/auth/refresh'— the cookie is only sent to the refresh endpoint, not to every API call, reducing the attack surface.sameSite: 'strict'— prevents CSRF attacks by not sending the cookie on cross-origin requests.
The access token is returned in the response body and sent by the client in the Authorization header. This separation means the access token is available to the frontend for attaching to API calls, while the refresh token is invisible to JavaScript.

Token Rotation: One-Time-Use Refresh Tokens
Token rotation means every call to the refresh endpoint invalidates the old refresh token and issues a new one. This turns a stolen refresh token into a single-use ticket rather than a persistent backdoor.
1// auth.controller.ts — refresh endpoint with rotation (a method on the AuthController above)
2@UseGuards(JwtRefreshAuthGuard)
3@Post('refresh')
4@HttpCode(200)
5async refresh(
6 @Req() req: Request,
7 @Res({ passthrough: true }) res: Response,
8) {
9 const refreshToken = req.cookies['refresh_token'];
10 if (!refreshToken) throw new UnauthorizedException('Refresh token missing');
11
12 // Extract user from the refresh token (set by JwtRefreshAuthGuard)
13 const user = req.user as any;
14 const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
15
16 // Find the stored token record
17 const storedToken = await this.refreshTokenRepo.findOneBy({
18 userId: user.id,
19 tokenHash,
20 revokedAt: null,
21 });
22
23 if (!storedToken) {
24 throw new UnauthorizedException('Invalid or revoked refresh token');
25 }
26
27 // Revoke the old token (rotation)
28 await this.refreshTokenRepo.update(
29 { id: storedToken.id },
30 { revokedAt: new Date() },
31 );
32
33 // Issue new token pair with same family_id
34 const payload = { sub: user.id, email: user.email };
35 const newAccessToken = this.jwtService.sign(payload);
36 const newRefreshToken = this.jwtService.sign(payload, {
37 secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
38 expiresIn: '30d',
39 });
40
41 const newHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex');
42
43 await this.refreshTokenRepo.insert({
44 userId: user.id,
45 tokenHash: newHash,
46 familyId: storedToken.familyId,
47 deviceName: storedToken.deviceName,
48 deviceIp: storedToken.deviceIp,
49 expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
50 });
51
52 // Set new cookie
53 res.cookie('refresh_token', newRefreshToken, {
54 httpOnly: true,
55 secure: process.env.NODE_ENV === 'production',
56 sameSite: 'strict',
57 path: '/auth/refresh',
58 maxAge: 30 * 24 * 60 * 60 * 1000,
59 });
60
61 return { accessToken: newAccessToken };
62}The old refresh token is soft-revoked (set revoked_at) and a new one is inserted with the same family_id. This is the core of rotation: the old token stops working, the new one takes its place, and the family lineage is preserved for reuse detection.
Refresh Token Reuse Detection
Reuse detection is what separates a toy auth system from a production one. If a refresh token has already been rotated (revoked) and someone tries to use it again, that means the token was intercepted.
1// Inside the refresh method, after finding the stored token:
2if (!storedToken) {
3 // Token not found — check if it was already revoked (reuse attempt)
4 const revokedToken = await this.refreshTokenRepo.findOneBy({
5 userId: user.id,
6 tokenHash,
7 });
8
9 if (revokedToken && revokedToken.revokedAt) {
10 // Reuse detected! Invalidate the entire family
11 await this.refreshTokenRepo.update(
12 { familyId: revokedToken.familyId, revokedAt: null },
13 { revokedAt: new Date() },
14 );
15
16 // Clear the cookie
17 res.clearCookie('refresh_token', { path: '/auth/refresh' });
18
19 throw new UnauthorizedException(
20 'Refresh token reuse detected — all sessions invalidated',
21 );
22 }
23
24 throw new UnauthorizedException('Invalid refresh token');
25}When a reused token is detected, we revoke every non-revoked token in the same family. This forces the legitimate user to re-authenticate on every device that was part of that family, which is the right response to a confirmed token compromise.
This is the mechanism that makes refresh token rotation meaningful. Without reuse detection, rotation is just ceremony.
Redis Blacklist for Instant Access-Token Revocation
Access tokens are stateless — you cannot revoke them. The standard workaround is to keep the expiry short (15 minutes), but sometimes you need immediate invalidation (password change, admin force-logout, detected compromise).
A Redis blacklist bridges the gap:
1// redis-blacklist.service.ts
2import { Injectable } from '@nestjs/common';
3import { InjectRedis } from '@nestjs-modules/ioredis';
4import Redis from 'ioredis';
5
6@Injectable()
7export class TokenBlacklistService {
8 constructor(@InjectRedis() private readonly redis: Redis) {}
9
10 async blacklistAccessToken(jti: string, expiresInSeconds: number): Promise<void> {
11 // Store the token ID in Redis until the token would have expired
12 await this.redis.set(
13 `blacklist:${jti}`,
14 'true',
15 'EX',
16 expiresInSeconds,
17 );
18 }
19
20 async isBlacklisted(jti: string): Promise<boolean> {
21 const result = await this.redis.get(`blacklist:${jti}`);
22 return result !== null;
23 }
24}Modify the JWT payload to include a jti (JWT ID):
1// In issueTokens:
2const jti = uuidv4();
3const accessToken = this.jwtService.sign({ ...payload, jti });Then check the blacklist in the JWT strategy:
1// jwt.strategy.ts
2import { Injectable, UnauthorizedException } from '@nestjs/common';
3import { PassportStrategy } from '@nestjs/passport';
4import { ExtractJwt, Strategy } from 'passport-jwt';
5import { ConfigService } from '@nestjs/config';
6import { TokenBlacklistService } from './redis-blacklist.service';
7import { InjectRepository } from '@nestjs/typeorm';
8import { Repository } from 'typeorm';
9import { User } from '../users/user.entity';
10
11@Injectable()
12export class JwtStrategy extends PassportStrategy(Strategy) {
13 constructor(
14 config: ConfigService,
15 private blacklistService: TokenBlacklistService,
16 @InjectRepository(User)
17 private userRepo: Repository<User>,
18 ) {
19 super({
20 jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
21 secretOrKey: config.get<string>('JWT_ACCESS_SECRET'),
22 });
23 }
24
25 async validate(payload: { sub: string; jti: string }) {
26 // Check Redis blacklist before trusting the token
27 const blacklisted = await this.blacklistService.isBlacklisted(payload.jti);
28 if (blacklisted) {
29 throw new UnauthorizedException('Token has been revoked');
30 }
31
32 const user = await this.userRepo.findOneBy({ id: payload.sub });
33 if (!user) {
34 throw new UnauthorizedException('User not found');
35 }
36 return user;
37 }
38}Redis handles this better than a database table because:
- Automatic TTL expiry means blacklist entries clean themselves up
- Sub-millisecond lookup times add no measurable latency
- No table bloat from expired entries
Logout: Single Device and All Devices
A production auth system needs both granular logout (end this session) and nuclear logout (end everything).
1// auth.controller.ts — logout endpoints on the same AuthController
2@Post('logout')
3@UseGuards(AuthGuard('jwt'))
4@HttpCode(200)
5async logout(
6 @Req() req: Request,
7 @Res({ passthrough: true }) res: Response,
8) {
9 const user = req.user as User;
10 const refreshToken = req.cookies['refresh_token'];
11
12 if (refreshToken) {
13 const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
14 await this.refreshTokenRepo.update(
15 { userId: user.id, tokenHash },
16 { revokedAt: new Date() },
17 );
18 }
19
20 // Blacklist the current access token
21 const authHeader = req.headers.authorization;
22 if (authHeader) {
23 const accessToken = authHeader.split(' ')[1];
24 const payload = this.jwtService.decode(accessToken) as any;
25 if (payload?.jti) {
26 // Blacklist for the remaining token lifetime
27 const exp = payload.exp - Math.floor(Date.now() / 1000);
28 await this.blacklistService.blacklistAccessToken(payload.jti, Math.max(exp, 0));
29 }
30 }
31
32 res.clearCookie('refresh_token', { path: '/auth/refresh' });
33 return { message: 'Logged out successfully' };
34}
35
36@Post('logout-all')
37@UseGuards(AuthGuard('jwt'))
38@HttpCode(200)
39async logoutAll(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
40 const user = req.user as User;
41
42 // Revoke ALL refresh tokens for this user
43 await this.refreshTokenRepo.update(
44 { userId: user.id, revokedAt: null },
45 { revokedAt: new Date() },
46 );
47
48 res.clearCookie('refresh_token', { path: '/auth/refresh' });
49 return { message: 'Logged out from all devices' };
50}Single-device logout revokes only the specific refresh token associated with the current cookie. All-devices logout revokes every active refresh token for the user, which means every device session dies on the next refresh attempt.
This is the feature that avoids the "changed my password, still logged in on my old phone" problem.
Testing the Auth Flow
Test the happy path and the attack scenarios:
1// auth.e2e-spec.ts
2describe('Auth Flow', () => {
3 it('should issue access + refresh tokens on login', async () => {
4 const res = await request(app)
5 .post('/auth/login')
6 .send({ email: 'test@test.com', password: 'password' });
7
8 expect(res.status).toBe(200);
9 expect(res.body.accessToken).toBeDefined();
10 expect(res.headers['set-cookie']).toBeDefined();
11 });
12
13 it('should rotate refresh token on use', async () => {
14 const loginRes = await request(app)
15 .post('/auth/login')
16 .send({ email: 'test@test.com', password: 'password' });
17
18 const cookies = loginRes.headers['set-cookie'];
19 const firstCookie = cookies[0].split(';')[0];
20
21 const refreshRes = await request(app)
22 .post('/auth/refresh')
23 .set('Cookie', firstCookie);
24
25 expect(refreshRes.status).toBe(200);
26 // Cookie should have changed (rotation)
27 expect(refreshRes.headers['set-cookie'][0]).not.toBe(firstCookie);
28 });
29
30 it('should detect token reuse and invalidate family', async () => {
31 const loginRes = await request(app)
32 .post('/auth/login')
33 .send({ email: 'test@test.com', password: 'password' });
34
35 const cookies = loginRes.headers['set-cookie'];
36 const firstCookie = cookies[0].split(';')[0];
37
38 // First use — valid
39 await request(app)
40 .post('/auth/refresh')
41 .set('Cookie', firstCookie);
42
43 // Second use with same token — should be detected as reuse
44 const reuseRes = await request(app)
45 .post('/auth/refresh')
46 .set('Cookie', firstCookie);
47
48 expect(reuseRes.status).toBe(401);
49 });
50
51 it('should reject blacklisted access tokens', async () => {
52 const res = await request(app)
53 .post('/auth/login')
54 .send({ email: 'test@test.com', password: 'password' });
55
56 const accessToken = res.body.accessToken;
57
58 // Logout to blacklist
59 await request(app)
60 .post('/auth/logout')
61 .set('Authorization', `Bearer ${accessToken}`);
62
63 // Try to use blacklisted token
64 const profileRes = await request(app)
65 .get('/auth/profile')
66 .set('Authorization', `Bearer ${accessToken}`);
67
68 expect(profileRes.status).toBe(401);
69 });
70});Every auth system should have these tests before it hits production. The reuse detection test in particular catches the kind of bug that would let a stolen token persist indefinitely.
JWT Passport Strategies
Here are both strategies that wire into NestJS Passport:
1// jwt.strategy.ts — validates access tokens
2import { Injectable, UnauthorizedException } from '@nestjs/common';
3import { PassportStrategy } from '@nestjs/passport';
4import { ExtractJwt, Strategy } from 'passport-jwt';
5import { ConfigService } from '@nestjs/config';
6
7@Injectable()
8export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
9 constructor(config: ConfigService) {
10 super({
11 jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
12 secretOrKey: config.get<string>('JWT_ACCESS_SECRET'),
13 });
14 }
15
16 async validate(payload: { sub: string; email: string }) {
17 return { id: payload.sub, email: payload.email };
18 }
19}1// jwt-refresh.strategy.ts — validates refresh tokens
2import { Injectable } from '@nestjs/common';
3import { PassportStrategy } from '@nestjs/passport';
4import { ExtractJwt, Strategy } from 'passport-jwt';
5import { ConfigService } from '@nestjs/config';
6
7@Injectable()
8export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
9 constructor(config: ConfigService) {
10 super({
11 jwtFromRequest: ExtractJwt.fromExtractors([
12 (req) => req?.cookies?.['refresh_token'] ?? null,
13 ]),
14 secretOrKey: config.get<string>('JWT_REFRESH_SECRET'),
15 passReqToCallback: true,
16 });
17 }
18
19 async validate(req: any, payload: { sub: string; email: string }) {
20 const refreshToken = req.cookies?.['refresh_token'];
21 return { ...payload, refreshToken };
22 }
23}The refresh strategy extracts the token from the httpOnly cookie instead of the Authorization header, keeping the access and refresh token paths completely separate. This separation is essential for a production-grade auth system.
External resources for deeper reading: the NestJS authentication documentation covers the base passport setup, the official passport-jwt documentation explains the extraction strategies, and the OWASP Authentication Cheat Sheet is the reference for getting the security details right. If you're new to NestJS modules, our NestJS project structure guide shows how we organize auth modules, and our multi-tenant database guide covers scoping those user records per tenant.
Conclusion
The single-token auth system I shipped took a day to build and months to regret. The two-token system with rotation, reuse detection, and a Redis blacklist took a week to build and has not been regretted once. A complete NestJS JWT refresh token setup takes time, but the alternative — a single stolen token with no recourse — costs more.
The difference is not the code that signs two JWTs. It's the failure modes you've thought about: rotation, reuse detection, and blacklisting. A stolen token is not a "that won't happen to us" problem — stolen credentials are the entry point for 24% of all breaches (Verizon DBIR 2024), and the average breach costs $4.88 million (IBM). Building auth that handles compromise gracefully is not premature paranoia. It's the minimum viable security posture for a SaaS that handles real user data.
If you are weighing whether token rotation and reuse detection are worth the extra week: they are. The week you spend now is the week you do not spend responding to a support ticket that starts with "somebody logged into my account and I don't know how." If you want a second set of eyes on your auth flow before it ships, that's a conversation we have often.
Frequently Asked Questions
A JWT refresh token is a long-lived token (typically 7–30 days) used to obtain new short-lived access tokens without requiring the user to re-enter credentials. In NestJS, you implement it alongside a short-lived access token (5–15 minutes) using @nestjs/jwt and @nestjs/passport. The refresh token is stored in the database or an httpOnly cookie and is revocable, unlike the stateless access token.
Token rotation means every time a refresh token is used to get a new access token, the server also issues a new refresh token and invalidates the old one. This limits the damage if a refresh token is intercepted, because each token can only be used once. If a stolen refresh token is used after the legitimate user already rotated it, the server detects the reuse and can invalidate the entire token family.
Both. The refresh token itself should be stored in an httpOnly cookie (XSS-protected) and its hash should be stored in the database for server-side validation, revocation, and device tracking. The database record is the source of truth; the cookie is the transport. This dual approach gives you revocation capability (database) and XSS protection (httpOnly cookie).
Delete the refresh token record from the database on logout. For single-device logout, delete only the specific token record associated with that device. For all-devices logout, delete every refresh token record belonging to the user. Add a Redis blacklist to also invalidate the access token immediately, since access tokens are stateless and cannot be revoked otherwise until they expire.
Reuse detection detects when a stolen refresh token is used after the legitimate user already rotated it. If a token for a given family has already been consumed, any subsequent use of the same token is flagged as suspicious. The server can then invalidate the entire token family for that user, forcing re-authentication on all devices. This is an important security mechanism for production auth systems.
