Implementing JWT Refresh Tokens: Secure Token Management
JWT (JSON Web Tokens) are widely used for authentication, but managing token expiration and security requires careful implementation. Refresh tokens provide a secure way to maintain user sessions while keeping access tokens short-lived. In this article, we'll explore how to implement a robust refresh token system.
Why Refresh Tokens?
Security Benefits
- Short-lived Access Tokens: Access tokens expire quickly (15 minutes), limiting exposure if compromised
- Long-lived Sessions: Refresh tokens allow users to stay logged in without frequent re-authentication
- Token Rotation: Refresh tokens can be rotated on each use, invalidating old tokens
- Revocation: Refresh tokens can be revoked independently of access tokens
Token Lifecycle
- User logs in → Receives access token (15 min) + refresh token (7 days)
- Access token expires → Use refresh token to get new access token
- Refresh token expires → User must log in again
Implementation
1. Token Generation
Generate both access and refresh tokens:
// apps/backend/src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private configService: ConfigService,
) {}
async generateTokens(user: User): Promise<{
access_token: string;
refresh_token: string;
}> {
const payload = {
sub: user.id,
email: user.email,
role: user.role,
};
// Short-lived access token
const accessToken = this.jwtService.sign(payload, {
secret: this.configService.get<string>('JWT_SECRET'),
expiresIn: this.configService.get<string>('JWT_EXPIRES_IN', '15m'),
});
// Long-lived refresh token
const refreshToken = this.jwtService.sign(payload, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES_IN', '7d'),
});
return {
access_token: accessToken,
refresh_token: refreshToken,
};
}
}2. Refresh Token Storage
Store refresh tokens in database for revocation:
// apps/backend/src/users/entities/refresh-token.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn } from 'typeorm';
import { User } from './user.entity';
@Entity('refresh_tokens')
export class RefreshToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ type: 'varchar', length: 500, unique: true })
token: string;
@Column({ type: 'timestamp', name: 'expires_at' })
expiresAt: Date;
@Column({ type: 'boolean', default: false })
revoked: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}3. Token Refresh Endpoint
Implement refresh endpoint with token rotation:
// apps/backend/src/auth/auth.service.ts
async refresh(refreshToken: string): Promise<{ access_token: string; refresh_token?: string }> {
try {
// Verify refresh token
const payload = this.jwtService.verify(refreshToken, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
});
// Check if token exists in database and is not revoked
const storedToken = await this.refreshTokenRepository.findOne({
where: { token: refreshToken },
relations: ['user'],
});
if (!storedToken || storedToken.revoked || storedToken.expiresAt < new Date()) {
throw new UnauthorizedException('Invalid or expired refresh token');
}
const user = storedToken.user;
if (!user.isActive) {
throw new UnauthorizedException('User account is inactive');
}
// Revoke old refresh token
storedToken.revoked = true;
await this.refreshTokenRepository.save(storedToken);
// Generate new tokens
const newPayload = { sub: user.id, email: user.email, role: user.role };
const newAccessToken = this.jwtService.sign(newPayload);
// Optionally rotate refresh token (recommended for security)
const newRefreshToken = this.jwtService.sign(newPayload, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES_IN', '7d'),
});
// Store new refresh token
const newStoredToken = this.refreshTokenRepository.create({
user,
token: newRefreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
});
await this.refreshTokenRepository.save(newStoredToken);
return {
access_token: newAccessToken,
refresh_token: newRefreshToken, // Return new refresh token
};
} catch (error) {
throw new UnauthorizedException('Invalid refresh token');
}
}4. Token Revocation
Implement token revocation for logout:
async logout(refreshToken: string): Promise<void> {
const storedToken = await this.refreshTokenRepository.findOne({
where: { token: refreshToken },
});
if (storedToken) {
storedToken.revoked = true;
await this.refreshTokenRepository.save(storedToken);
}
// Optionally revoke all tokens for the user
// await this.refreshTokenRepository.update(
// { user: { id: userId } },
// { revoked: true }
// );
}5. Controller Endpoints
Expose refresh and logout endpoints:
// apps/backend/src/auth/auth.controller.ts
import { Controller, Post, Body, HttpCode } from '@nestjs/common';
import { AuthService } from './auth.service';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('refresh')
@HttpCode(200)
async refresh(@Body() body: { refresh_token: string }) {
return this.authService.refresh(body.refresh_token);
}
@Post('logout')
@HttpCode(200)
async logout(@Body() body: { refresh_token: string }) {
await this.authService.logout(body.refresh_token);
return { message: 'Logged out successfully' };
}
}6. Frontend: Token Management
Store tokens securely and handle refresh automatically:
// apps/portal/src/lib/auth/token-manager.ts
class TokenManager {
private accessToken: string | null = null;
private refreshToken: string | null = null;
setTokens(accessToken: string, refreshToken: string) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
// Store in httpOnly cookies (more secure) or localStorage
localStorage.setItem('access_token', accessToken);
localStorage.setItem('refresh_token', refreshToken);
}
getAccessToken(): string | null {
return this.accessToken || localStorage.getItem('access_token');
}
getRefreshToken(): string | null {
return this.refreshToken || localStorage.getItem('refresh_token');
}
async refreshAccessToken(): Promise<string | null> {
const refreshToken = this.getRefreshToken();
if (!refreshToken) {
return null;
}
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!response.ok) {
this.clearTokens();
return null;
}
const { access_token, refresh_token } = await response.json();
this.setTokens(access_token, refresh_token || refreshToken);
return access_token;
} catch (error) {
this.clearTokens();
return null;
}
}
clearTokens() {
this.accessToken = null;
this.refreshToken = null;
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
}
}
export const tokenManager = new TokenManager();7. Frontend: Axios Interceptor
Automatically refresh tokens on 401 errors:
// apps/portal/src/lib/api/client.ts
import axios from 'axios';
import { tokenManager } from '../auth/token-manager';
const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
});
// Add access token to requests
apiClient.interceptors.request.use((config) => {
const token = tokenManager.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle token refresh on 401
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If 401 and haven't tried to refresh yet
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const newAccessToken = await tokenManager.refreshAccessToken();
if (newAccessToken) {
// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return apiClient(originalRequest);
} else {
// Refresh failed, redirect to login
window.location.href = '/login';
return Promise.reject(error);
}
}
return Promise.reject(error);
},
);Security Best Practices
1. Token Storage
Backend: Store refresh tokens in database for revocation Frontend: Use httpOnly cookies when possible (prevents XSS attacks)
// Set httpOnly cookie (server-side)
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});2. Token Rotation
Rotate refresh tokens on each use to prevent token reuse:
// Issue new refresh token on each refresh
const newRefreshToken = this.jwtService.sign(newPayload, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn: '7d',
});3. Token Expiration
Use appropriate expiration times:
- Access Token: 15 minutes (short-lived)
- Refresh Token: 7 days (long-lived but revocable)
4. Revocation Strategy
Implement token revocation:
- Single Token: Revoke specific token on logout
- User Tokens: Revoke all tokens for a user (on password change, etc.)
- Device Tokens: Track device and revoke by device
5. Rate Limiting
Protect refresh endpoint from abuse:
@Post('refresh')
@UseGuards(ThrottlerGuard)
@Throttle(5, 60) // 5 requests per minute
async refresh(@Body() body: { refresh_token: string }) {
return this.authService.refresh(body.refresh_token);
}Token Cleanup
Clean up expired tokens periodically:
// Scheduled task to clean expired tokens
@Cron(CronExpression.EVERY_DAY_AT_2AM)
async cleanupExpiredTokens() {
await this.refreshTokenRepository.delete({
expiresAt: LessThan(new Date()),
});
}Conclusion
JWT refresh tokens provide a secure way to manage user sessions while keeping access tokens short-lived. By implementing token rotation, revocation, and proper storage, you create a robust authentication system that balances security and user experience. Remember to use httpOnly cookies when possible, implement rate limiting, and clean up expired tokens regularly.



