Building a Secure JWT Authentication System with Refresh Tokens
Learn how to implement stateless JWT authentication with refresh tokens, automatic rotation, and proper security measures for production apps.
#authentication#jwt#security#backend
The Authentication Challenge
When building Aegis2FA, I needed an authentication system that was:
- Stateless - No session storage required
- Scalable - Works across multiple servers
- Secure - Resistant to token theft and replay attacks
- User-friendly - No constant re-authentication
The solution: JWT access tokens + HttpOnly refresh tokens.
The Two-Token System
Access Tokens (Short-lived)
interface AccessTokenPayload {
sub: string; // User ID
email: string;
role: 'user' | 'admin';
iat: number; // Issued at
exp: number; // Expires at (15 minutes)
}Properties:
- Valid for 15 minutes
- Sent in Authorization header
- Used for API requests
- Stored in memory (not localStorage!)
Refresh Tokens (Long-lived)
interface RefreshTokenPayload {
sub: string; // User ID
tokenId: string; // Unique token ID
iat: number;
exp: number; // Expires at (7 days)
}Properties:
- Valid for 7 days
- Stored in HttpOnly cookie
- Used to get new access tokens
- Automatically rotated on use
Implementation
Token Generation
import jwt from 'jsonwebtoken';
import { randomUUID } from 'crypto';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
export function generateAccessToken(user: User): string {
const payload: AccessTokenPayload = {
sub: user.id,
email: user.email,
role: user.role,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 15 * 60, // 15 minutes
};
return jwt.sign(payload, ACCESS_SECRET, {
algorithm: 'HS256',
});
}
export function generateRefreshToken(userId: string): {
token: string;
tokenId: string;
} {
const tokenId = randomUUID();
const payload: RefreshTokenPayload = {
sub: userId,
tokenId,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 days
};
const token = jwt.sign(payload, REFRESH_SECRET, {
algorithm: 'HS256',
});
return { token, tokenId };
}Login Flow
import { Response } from 'express';
import argon2 from 'argon2';
export async function login(
email: string,
password: string,
res: Response
): Promise<{ accessToken: string; user: UserDto }> {
// Find user
const user = await db.user.findUnique({
where: { email },
});
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
// Verify password
const valid = await argon2.verify(user.passwordHash, password);
if (!valid) {
throw new UnauthorizedException('Invalid credentials');
}
// Check if 2FA is enabled
if (user.totpEnabled) {
// Return partial token, require 2FA verification
return {
requires2FA: true,
tempToken: generateTempToken(user.id),
};
}
// Generate tokens
const accessToken = generateAccessToken(user);
const { token: refreshToken, tokenId } = generateRefreshToken(user.id);
// Store refresh token in database
await db.refreshToken.create({
data: {
id: tokenId,
userId: user.id,
token: await hashToken(refreshToken),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
// Set HttpOnly cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/auth/refresh',
});
return {
accessToken,
user: sanitizeUser(user),
};
}Token Refresh Flow
export async function refresh(
oldRefreshToken: string,
res: Response
): Promise<{ accessToken: string }> {
// Verify refresh token
let payload: RefreshTokenPayload;
try {
payload = jwt.verify(oldRefreshToken, REFRESH_SECRET) as RefreshTokenPayload;
} catch (error) {
throw new UnauthorizedException('Invalid refresh token');
}
// Check if token exists in database
const storedToken = await db.refreshToken.findUnique({
where: { id: payload.tokenId },
include: { user: true },
});
if (!storedToken) {
// Token was revoked or never existed
throw new UnauthorizedException('Token revoked');
}
// Verify hash matches
const valid = await verifyToken(oldRefreshToken, storedToken.token);
if (!valid) {
// Possible token theft - revoke all tokens
await revokeAllUserTokens(payload.sub);
throw new UnauthorizedException('Invalid token');
}
// Delete old refresh token
await db.refreshToken.delete({
where: { id: payload.tokenId },
});
// Generate new tokens
const accessToken = generateAccessToken(storedToken.user);
const { token: newRefreshToken, tokenId } = generateRefreshToken(payload.sub);
// Store new refresh token
await db.refreshToken.create({
data: {
id: tokenId,
userId: payload.sub,
token: await hashToken(newRefreshToken),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
// Set new cookie
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/auth/refresh',
});
return { accessToken };
}Authentication Middleware
import { Request, Response, NextFunction } from 'express';
export interface AuthRequest extends Request {
user?: AccessTokenPayload;
}
export function authenticate(
req: AuthRequest,
res: Response,
next: NextFunction
) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.substring(7);
try {
const payload = jwt.verify(token, ACCESS_SECRET) as AccessTokenPayload;
req.user = payload;
next();
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
return res.status(401).json({
error: 'Token expired',
code: 'TOKEN_EXPIRED',
});
}
return res.status(401).json({ error: 'Invalid token' });
}
}Security Best Practices
1. Token Storage
Access Token:
// Frontend - Store in memory only
let accessToken: string | null = null;
export function setAccessToken(token: string) {
accessToken = token;
}
export function getAccessToken(): string | null {
return accessToken;
}
// Never localStorage.setItem('accessToken', ...) !!Refresh Token:
// Backend - HttpOnly cookie only
res.cookie('refreshToken', token, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
path: '/api/auth/refresh', // Limit scope
});2. Token Rotation
Always rotate refresh tokens on use:
async function rotateRefreshToken(
oldTokenId: string,
userId: string
): Promise<string> {
// Delete old token
await db.refreshToken.delete({
where: { id: oldTokenId },
});
// Generate new token
const { token, tokenId } = generateRefreshToken(userId);
// Store new token
await db.refreshToken.create({
data: {
id: tokenId,
userId,
token: await hashToken(token),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return token;
}3. Token Revocation
Implement token revocation for logout and security:
export async function logout(userId: string, tokenId: string): Promise<void> {
// Delete specific token
await db.refreshToken.delete({
where: { id: tokenId },
});
}
export async function logoutAll(userId: string): Promise<void> {
// Delete all user tokens
await db.refreshToken.deleteMany({
where: { userId },
});
}4. Token Theft Detection
Detect token reuse (possible theft):
async function detectTokenReuse(tokenId: string): Promise<boolean> {
// If token doesn't exist but was valid JWT, it was likely reused
const token = await db.refreshToken.findUnique({
where: { id: tokenId },
});
return token === null;
}
// In refresh endpoint
if (await detectTokenReuse(payload.tokenId)) {
// Revoke all tokens for this user
await revokeAllUserTokens(payload.sub);
// Log security event
await logSecurityEvent({
userId: payload.sub,
event: 'POSSIBLE_TOKEN_THEFT',
severity: 'HIGH',
});
throw new UnauthorizedException('Token reuse detected');
}Frontend Integration
Axios Interceptor
import axios, { AxiosError } from 'axios';
const api = axios.create({
baseURL: '/api',
});
// Add token to requests
api.interceptors.request.use((config) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle token expiration
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config;
// If token expired, try to refresh
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const { data } = await axios.post('/api/auth/refresh', null, {
withCredentials: true, // Send refresh token cookie
});
setAccessToken(data.accessToken);
// Retry original request
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return api(originalRequest);
} catch (refreshError) {
// Refresh failed, redirect to login
setAccessToken(null);
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);React Hook
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
export function useTokenRefresh() {
const navigate = useNavigate();
useEffect(() => {
// Refresh token before expiry (14 minutes)
const interval = setInterval(async () => {
try {
const { data } = await api.post('/api/auth/refresh');
setAccessToken(data.accessToken);
} catch (error) {
// Refresh failed
setAccessToken(null);
navigate('/login');
}
}, 14 * 60 * 1000); // 14 minutes
return () => clearInterval(interval);
}, [navigate]);
}Testing
Unit Tests
import { describe, it, expect, beforeEach } from 'vitest';
describe('Token Generation', () => {
it('should generate valid access token', () => {
const user = { id: '123', email: 'test@test.com', role: 'user' };
const token = generateAccessToken(user);
const payload = jwt.verify(token, ACCESS_SECRET) as AccessTokenPayload;
expect(payload.sub).toBe('123');
expect(payload.email).toBe('test@test.com');
expect(payload.exp - payload.iat).toBe(15 * 60);
});
it('should generate unique refresh token IDs', () => {
const token1 = generateRefreshToken('user1');
const token2 = generateRefreshToken('user1');
expect(token1.tokenId).not.toBe(token2.tokenId);
});
});
describe('Token Refresh', () => {
it('should rotate refresh token', async () => {
const { token: oldToken, tokenId: oldId } = generateRefreshToken('user1');
await storeRefreshToken(oldId, 'user1', oldToken);
const res = { cookie: vi.fn() };
const { accessToken } = await refresh(oldToken, res);
expect(accessToken).toBeTruthy();
// Old token should be deleted
const oldStored = await db.refreshToken.findUnique({
where: { id: oldId },
});
expect(oldStored).toBeNull();
});
});Performance Optimization
Token Caching
import NodeCache from 'node-cache';
const tokenCache = new NodeCache({
stdTTL: 900, // 15 minutes
checkperiod: 120,
});
export async function verifyAccessToken(
token: string
): Promise<AccessTokenPayload | null> {
// Check cache first
const cached = tokenCache.get<AccessTokenPayload>(token);
if (cached) {
return cached;
}
try {
const payload = jwt.verify(token, ACCESS_SECRET) as AccessTokenPayload;
// Cache token
const ttl = payload.exp - Math.floor(Date.now() / 1000);
tokenCache.set(token, payload, ttl);
return payload;
} catch (error) {
return null;
}
}Results in Production
After 6 months of Aegis2FA in production:
- Zero token breaches
- 99.99% uptime for auth endpoints
- Sub-50ms token verification with caching
- Automatic token rotation working flawlessly
- Zero XSS token theft thanks to HttpOnly cookies
Conclusion
The two-token JWT system provides:
- Stateless authentication
- Automatic security through rotation
- Protection against XSS and CSRF
- Smooth user experience
Key takeaways:
- Short-lived access tokens (15 min)
- HttpOnly refresh tokens (7 days)
- Rotate on every use
- Store tokens in database for revocation
- Never use localStorage for tokens
Questions about JWT implementation? Need help with token rotation? Reach out or check the Aegis2FA source code!