Published Updated on 6 min read
NestJS JWT Refresh Tokens: Rotation and Cookies
Use a short-lived JWT access token (about 15 minutes) sent in the Authorization header, and a long-lived random refresh token sent only in an httpOnly cookie. Store a SHA-256 hash of each refresh token, rotate it on every use, and revoke the whole token family if an already-used token comes back, because that means it was stolen.
A refresh token flow in NestJS is two tokens with two jobs: a short-lived JWT that proves who you are on every request, and a long-lived random token that can only be used to get a new JWT. This post builds the whole flow in NestJS 11 on Node.js 22 with TypeScript strict mode: login, refresh with rotation, reuse detection, logout and the guard that protects routes.
I'm using @nestjs/jwt for signing, Prisma for storage and Node's built-in crypto module for random tokens and hashing. The code was written against these versions in November 2026.
What is a refresh token and why do I need one?#
A refresh token is a long-lived credential whose only purpose is to obtain new access tokens, so the access token can expire quickly.
A JWT (JSON Web Token) is a signed string your API can verify without a database lookup. That's the appeal, and it's also the problem: once issued, a JWT is valid until its exp claim, and you can't take it back. If you give users a JWT that lasts 30 days, a leaked token is a 30-day key to their account.
Splitting the job into two tokens fixes that:
| Access token | Refresh token | |
|---|---|---|
| Format | Signed JWT | 32 random bytes, base64url |
| Lifetime | 15 minutes | 7 to 30 days |
| Sent in | Authorization: Bearer header | httpOnly cookie, only to /auth |
| Checked against the database? | No | Yes, every time |
| Stored on the server as | Nothing | SHA-256 hash |
| Can be revoked early? | No, it just expires | Yes |
The refresh token doesn't need to be a JWT at all. Since the server looks it up anyway, an opaque random string is simpler and leaks no claims if someone decodes it.
How do I store refresh tokens safely?#
Store only a hash of each refresh token, along with who it belongs to, which family it's part of and whether it has been used.
If your database leaks, plain refresh tokens would let an attacker log in as every user. A SHA-256 hash is enough here because the token is 256 bits of randomness; there's no dictionary to guess from, so a slow password hash like argon2 only adds latency. Lookups also stay fast because the hash can be a unique index.
Here is the Prisma model:
model RefreshToken {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tokenHash String @unique
familyId String
expiresAt DateTime
usedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
@@index([familyId])
@@index([userId])
}A token family is every refresh token descended from one login. Rotation creates a new member of the family each time, and reuse detection revokes the whole family at once.
Two small helpers do the crypto:
// src/auth/token-crypto.ts
import { createHash, randomBytes } from 'node:crypto';
export function generateRefreshToken(): string {
return randomBytes(32).toString('base64url');
}
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}If you inherit code that hashes refresh tokens with bcrypt, check what it is hashing. bcrypt ignores everything after the first 72 bytes of input, and two JWTs for the same user share a long identical prefix in their header and payload, so an old token can still match the stored hash of a new one. An opaque random token hashed with SHA-256 avoids the problem entirely.
How do I issue tokens at login?#
At login, verify the password, sign a JWT access token, create a new refresh token family, and set the refresh token as an httpOnly cookie.
First, the module wiring. JwtModule.registerAsync reads the secret from config so it never sits in code:
// src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PrismaModule } from '../prisma/prisma.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
imports: [
PrismaModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
signOptions: { expiresIn: '15m' },
}),
}),
],
controllers: [AuthController],
providers: [AuthService],
exports: [JwtModule],
})
export class AuthModule {}The service does the real work. Password hashing uses argon2 here, because passwords are low-entropy and need a slow hash:
// src/auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { randomUUID } from 'node:crypto';
import * as argon2 from 'argon2';
import { PrismaService } from '../prisma/prisma.service';
import { generateRefreshToken, hashToken } from './token-crypto';
const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
export interface TokenPair {
accessToken: string;
refreshToken: string;
refreshExpiresAt: Date;
}
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
) {}
async login(email: string, password: string): Promise<TokenPair> {
const user = await this.prisma.user.findUnique({ where: { email } });
const ok = user !== null && (await argon2.verify(user.passwordHash, password));
if (!user || !ok) throw new UnauthorizedException('Invalid email or password');
return this.issue(user.id, user.roles, randomUUID());
}
private async issue(userId: string, roles: string[], familyId: string): Promise<TokenPair> {
const refreshToken = generateRefreshToken();
const refreshExpiresAt = new Date(Date.now() + REFRESH_TTL_MS);
await this.prisma.refreshToken.create({
data: { userId, familyId, tokenHash: hashToken(refreshToken), expiresAt: refreshExpiresAt },
});
const accessToken = await this.jwt.signAsync({ sub: userId, roles });
return { accessToken, refreshToken, refreshExpiresAt };
}
}Note that the same error message is used for "no such user" and "wrong password". Different messages tell an attacker which emails are registered.
How do I set the refresh token as an httpOnly cookie?#
Set it with res.cookie() using httpOnly, secure, sameSite and a path limited to the auth routes, and return only the access token in the JSON body.
Using @Res({ passthrough: true }) lets you set the cookie while still returning a value the NestJS way. Reading cookies needs the cookie-parser middleware, registered in main.ts with app.use(cookieParser()).
// src/auth/auth.controller.ts
import { Body, Controller, HttpCode, Post, Req, Res, UnauthorizedException } from '@nestjs/common';
import type { Request, Response } from 'express';
import { AuthService, type TokenPair } from './auth.service';
import { LoginDto } from './dto/login.dto';
const COOKIE = 'refresh_token';
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('login')
@HttpCode(200)
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const tokens = await this.auth.login(dto.email, dto.password);
this.setCookie(res, tokens);
return { accessToken: tokens.accessToken };
}
@Post('refresh')
@HttpCode(200)
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const token: unknown = req.cookies?.[COOKIE];
if (typeof token !== 'string') throw new UnauthorizedException();
const tokens = await this.auth.refresh(token);
this.setCookie(res, tokens);
return { accessToken: tokens.accessToken };
}
@Post('logout')
@HttpCode(204)
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const token: unknown = req.cookies?.[COOKIE];
if (typeof token === 'string') await this.auth.logout(token);
res.clearCookie(COOKIE, { path: '/auth' });
}
private setCookie(res: Response, tokens: TokenPair): void {
res.cookie(COOKIE, tokens.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/auth',
expires: tokens.refreshExpiresAt,
});
}
}What each cookie option does:
httpOnly: truehides the cookie fromdocument.cookie, so an XSS bug can't read it.secure: truesends it only over HTTPS. Chromium-based browsers and Firefox treathttp://localhostas secure, so local development still works there; Safari may need HTTPS locally.sameSite: 'strict'stops the browser sending it on cross-site requests, which blocks most CSRF against/auth/refresh. Use'lax'if your frontend lives on a different site and you add a CSRF token instead.path: '/auth'means the cookie isn't attached to every API call, only to login, refresh and logout.
The cookie flags are documented on MDN's Set-Cookie page, which is worth reading once in full.
How does refresh token rotation work?#
On every refresh, the server marks the presented token as used and issues a new one in the same family; if a token that's already used comes back, it revokes the family.
Here's the reasoning. Suppose an attacker copies a user's refresh token. Both now hold the same token. Whoever refreshes first gets a new token and the old one is marked used. When the other party tries the old token, the server sees a token that was already used, which can only happen if two parties hold it. It can't tell which one is the attacker, so it revokes the whole family and both must log in again. The real user can do that; the attacker can't.
// src/auth/auth.service.ts (continued, inside AuthService)
async refresh(presented: string): Promise<TokenPair> {
const row = await this.prisma.refreshToken.findUnique({
where: { tokenHash: hashToken(presented) },
include: { user: { select: { id: true, roles: true } } },
});
if (!row || row.revokedAt || row.expiresAt.getTime() <= Date.now()) {
throw new UnauthorizedException();
}
// Mark as used only if nobody else has used it yet (handles concurrent refreshes).
const { count } = await this.prisma.refreshToken.updateMany({
where: { id: row.id, usedAt: null },
data: { usedAt: new Date() },
});
if (count === 0) {
// Reuse detected: this token was already rotated. Kill the whole family.
await this.prisma.refreshToken.updateMany({
where: { familyId: row.familyId, revokedAt: null },
data: { revokedAt: new Date() },
});
throw new UnauthorizedException('Refresh token reuse detected');
}
return this.issue(row.user.id, row.user.roles, row.familyId);
}
async logout(presented: string): Promise<void> {
const row = await this.prisma.refreshToken.findUnique({
where: { tokenHash: hashToken(presented) },
});
if (!row) return;
await this.prisma.refreshToken.updateMany({
where: { familyId: row.familyId, revokedAt: null },
data: { revokedAt: new Date() },
});
}The updateMany with usedAt: null in the where clause matters. PostgreSQL applies that update atomically, so if two refresh requests with the same token arrive at the same moment, only one gets count === 1. Without it, both would read usedAt as empty and both would get new tokens.
That race isn't only an attack scenario. A single-page app that fires two API calls on page load, both of which see an expired access token, will call /auth/refresh twice. With strict reuse detection that logs the user out. The fix belongs in the frontend: share one in-flight refresh promise so parallel calls wait for the same result. Some teams also allow a grace period of a few seconds for the previous token; I prefer fixing the client.
How do I protect routes with the access token?#
Write a guard that reads the Bearer header, verifies the JWT with JwtService.verifyAsync, and attaches the payload to request.user.
This mirrors the guard in the NestJS authentication docs. Passport works too, but for a single JWT strategy a 25-line guard is easier to read:
// src/auth/jwt-auth.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import type { Request } from 'express';
export interface AccessPayload {
sub: string;
roles: string[];
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly jwt: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request & { user?: AccessPayload }>();
const [type, token] = req.headers.authorization?.split(' ') ?? [];
if (type !== 'Bearer' || !token) throw new UnauthorizedException();
try {
req.user = await this.jwt.verifyAsync<AccessPayload>(token);
return true;
} catch {
throw new UnauthorizedException();
}
}
}The payload includes roles, which the roles guard in my RBAC post reads. Remember that roles inside an access token are stale for up to 15 minutes after you change them. If you demote an admin, the change takes effect on their next refresh, because issue() reads roles fresh from the database.
What else should I clean up?#
Delete expired and revoked rows on a schedule, and cap how long a family can live.
Rotation means the table grows by one row per refresh. A nightly job with @nestjs/schedule that deletes rows where expiresAt is in the past keeps it small. I also store the family's original login time (or read the oldest row) and refuse to rotate past an absolute limit such as 90 days, so an active session still has to log in again eventually.
Changing a password should revoke every family for that user. That's one updateMany on userId.
The NestJS REST API with RBAC concept project uses this design: short-lived access tokens and rotating refresh tokens stored as hashes. You can log in with a demo account and call the refresh and logout endpoints from the Swagger UI in my NestJS REST API demo. More of my backend work is on the NestJS skill page.
What should you do first?#
Check how your current API stores refresh tokens. Run a query against the table and look at the column: if you can read a usable token in plain text, change it to store hashToken(token) and look rows up by hash, which is a small migration plus a forced re-login. Then add the usedAt check with a conditional updateMany. Those two changes close the biggest holes, and the cookie settings can follow in the next release.
Frequently asked questions
Why use refresh tokens instead of one long-lived JWT?
A JWT cannot be revoked before it expires unless you add a server-side check, so a stolen long-lived token works until expiry. With refresh tokens the access token lives only minutes and needs no database lookup, while the refresh token is checked against the database every time. You keep fast stateless requests and still have a way to log a user out or block a stolen session.
Should refresh tokens be stored in localStorage or cookies?
Store them in an httpOnly, Secure cookie with a narrow path such as /auth. JavaScript on the page cannot read an httpOnly cookie, so a cross-site scripting bug cannot copy the refresh token out. Pair the cookie with SameSite=Strict or Lax and a CSRF check on the refresh endpoint. The short-lived access token can stay in memory in the frontend.
Should I hash refresh tokens with bcrypt?
For random refresh tokens, SHA-256 is enough because the token already has 256 bits of entropy, so brute force is not realistic and lookups stay fast. Avoid bcrypt for JWT-shaped refresh tokens: bcrypt only uses the first 72 bytes of input, and two JWTs for the same user often share those bytes, so an old token can match a new hash.
What is refresh token rotation?
Refresh token rotation means every successful refresh returns a new refresh token and marks the old one as used. If an attacker and the real user both hold the same token, whoever uses it second presents a token that is already used. The server treats that as theft and revokes every token in the family, forcing a fresh login.
How long should access and refresh tokens last?
A common starting point is 15 minutes for access tokens and 7 to 30 days for refresh tokens. Shorter access tokens limit damage if one leaks but cause more refresh calls. Refresh lifetime is a product decision: a banking app might use one day, a booking dashboard used daily might use 30 days. Also cap the absolute session length so rotation cannot extend it forever.
Where I used this
- Case study: NestJS REST API with Auth, RBAC and Swagger · ▶ live demo
- Case study: Multi-tenant SaaS Backend with NestJS and PostgreSQL · ▶ live demo
- Skill: Node.js
- Skill: NestJS
- Skill: PostgreSQL