Implementing JWT Refresh Tokens: Secure Token Management
Learn how to implement secure JWT refresh tokens for long-lived sessions, including token rotation, revocation, and best security practices.

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/api/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/api/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/api/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/api/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.
References
Want more insights?
Subscribe to our newsletter or follow us for more updates on software development and team scaling.
Contact Us