NestJS Refresh Tokens
Access tokens should expire quickly — typically in 15 minutes to one hour — to limit the damage if a token is stolen. But expiring tokens means users must log in again frequently, which is a poor experience. Refresh tokens solve this problem: a long-lived token that silently issues new access tokens when the current one expires, without requiring the user to enter their password again.
Two-Token System
Login Response:
{
"access_token": "eyJhb...", ← short-lived (15 min), sent with every API request
"refresh_token": "dKj9x..." ← long-lived (7 days), stored securely, sent only to /auth/refresh
}
Normal Request:
GET /users/profile
Authorization: Bearer <access_token>
When Access Token Expires:
POST /auth/refresh
Authorization: Bearer <refresh_token>
→ New access_token returned (and new refresh_token)
Refresh Token Flow Diagram
User logs in
|
v
Server returns access_token (15min) + refresh_token (7 days)
|
| (15 minutes pass)
|
v
Client detects 401 Unauthorized on next request
|
v
Client sends POST /auth/refresh with refresh_token
|
v
Server validates refresh_token against database
|
v
Server issues new access_token + rotates refresh_token
|
v
Client stores new tokens and retries original request
Storing Refresh Tokens Securely
Unlike access tokens (stateless), refresh tokens must be stored server-side so they can be invalidated (e.g., on logout or after a security breach). Store a hashed version in the database alongside the user:
// user.entity.ts
@Entity()
export class User {
@PrimaryGeneratedColumn() id: number;
@Column() name: string;
@Column({ nullable: true, select: false })
refreshToken: string | null; // stores the hashed refresh token
}
Auth Service — Generating and Storing Tokens
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private usersService: UsersService,
) {}
async login(user: User) {
const tokens = await this.getTokens(user.id, user.email);
await this.updateRefreshToken(user.id, tokens.refresh_token);
return tokens;
}
async getTokens(userId: number, email: string) {
const payload = { sub: userId, email };
const [access_token, refresh_token] = await Promise.all([
this.jwtService.signAsync(payload, {
secret: process.env.JWT_ACCESS_SECRET,
expiresIn: '15m',
}),
this.jwtService.signAsync(payload, {
secret: process.env.JWT_REFRESH_SECRET,
expiresIn: '7d',
}),
]);
return { access_token, refresh_token };
}
async updateRefreshToken(userId: number, refreshToken: string) {
const hashed = await bcrypt.hash(refreshToken, 10);
await this.usersService.update(userId, { refreshToken: hashed });
}
}
Refresh Endpoint
// auth.controller.ts
@Post('refresh')
@UseGuards(RefreshTokenGuard)
async refresh(@Req() req) {
const userId = req.user.sub;
const refreshToken = req.user.refreshToken;
return this.authService.refreshTokens(userId, refreshToken);
}
// auth.service.ts
async refreshTokens(userId: number, refreshToken: string) {
const user = await this.usersService.findOneWithRefreshToken(userId);
if (!user || !user.refreshToken) {
throw new ForbiddenException('Access denied');
}
const matches = await bcrypt.compare(refreshToken, user.refreshToken);
if (!matches) {
throw new ForbiddenException('Access denied');
}
const tokens = await this.getTokens(user.id, user.email);
await this.updateRefreshToken(user.id, tokens.refresh_token);
return tokens;
}
Refresh Token Strategy
@Injectable()
export class RefreshTokenStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_REFRESH_SECRET,
passReqToCallback: true,
});
}
validate(req: Request, payload: any) {
const refreshToken = req.get('Authorization').replace('Bearer', '').trim();
return { ...payload, refreshToken };
}
}
Logout — Invalidating the Refresh Token
async logout(userId: number) {
await this.usersService.update(userId, { refreshToken: null });
// refresh_token in DB is now null — the token is invalidated
}
Clearing the stored refresh token on logout means the token can never be used again, even if someone obtained a copy of it. This is the security advantage of storing refresh tokens server-side compared to keeping everything stateless.
Token Rotation
Every time a refresh token is used, issue a new one and invalidate the old one. This pattern — called refresh token rotation — limits the window of exposure if a refresh token leaks. If an attacker uses an old rotated token, the server detects the reuse (because the stored token no longer matches) and can invalidate the user's session entirely as a security measure.
The two-token system with short-lived access tokens and rotated refresh tokens gives your users a seamless experience while keeping the security posture strong across long-lived sessions.
