Back to blog

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!