DEV Community

Cover image for JWT Authentication Architecture: Access Tokens, Refresh Tokens, and Secure HttpOnly Cookies
DEVANSHU PATIL
DEVANSHU PATIL

Posted on AI-assisted

JWT Authentication Architecture: Access Tokens, Refresh Tokens, and Secure HttpOnly Cookies

JWT Authentication Architecture: Access Tokens, Refresh Tokens, and Secure HttpOnly Cookies

title: "JWT Authentication Architecture: Access Tokens, Refresh Tokens, and Secure HttpOnly Cookies"
published: true
published_at: "2026-11-13T09:00:00+05:30"
description: "A comprehensive, production-ready guide to designing a secure authentication system using short-lived JWT access tokens, database-backed refresh token rotation, and HttpOnly cookies to mitigate XSS and CSRF vulnerabilities."
tags: [security, nodejs, auth, webdev]
ai_disclosure_level: some_ai

Introduction

JSON Web Tokens (JWT) are ubiquitous in modern web architectures. They offer a self-contained, stateless mechanism for transmitting user identity between parties. However, implementing a robust JWT-based authentication system requires balancing usability, scalability, and strict security constraints. Storing tokens improperly exposes applications to Cross-Site Scripting (XSS) and Cross-Site Request Forgery (CSRF).

This article outlines a production-ready authentication architecture utilizing short-lived access tokens, database-persisted refresh token rotation, and SameSite HttpOnly cookies.

The Threat Model: XSS vs. CSRF

When designing token storage mechanisms, developers generally choose between two locations: browser storage (localStorage/sessionStorage) and HTTP cookies.

Storage Mechanism Vulnerable to XSS? Vulnerable to CSRF? Accessible via JavaScript?
localStorage Yes No Yes
sessionStorage Yes No Yes
HttpOnly Cookie No Yes (mitigated by SameSite) No

Why localStorage is Dangerous

localStorage is accessible via any JavaScript running on the page. If an application suffers from a single XSS vulnerability (e.g., via a compromised npm dependency or unsanitized user input), an attacker can execute a script to read the JWT and exfiltrate it to an external server.

The Solution: HttpOnly Cookies

By setting the HttpOnly flag on a cookie, the browser prevents client-side scripts from reading or modifying the cookie value. Even if an attacker executes arbitrary JavaScript via XSS, they cannot access the authentication tokens directly.

Architecture Overview

To balance stateless scalability with revocation capabilities, modern architectures split authentication into two distinct tokens:

  1. Access Token: Short-lived (e.g., 15 minutes), stateless JWT. Sent with every API request to authorize access to protected resources.
  2. Refresh Token: Long-lived (e.g., 7 days), stateful token stored in a database. Used exclusively to request a new access token when the current one expires.
[Client Browser]                          [Node.js API Server]           [Database]
       |                                           |                           |
       |--- 1. POST /login (credentials) --------->|                           |
       |                                           |--- Validate credentials --|
       |                                           |--- Generate tokens -------|
       |<-- 2. Set-Cookie (Refresh) + JSON (Access)|                           |
       |                                           |                           |
       |--- 3. GET /api/data (Bearer Access) ----->|                           |
       |                                           |--- Verify JWT Access ---- |
       |<-- 4. Protected Resource -----------------|                           |
       |                                           |                           |
       |--- 5. POST /refresh (Cookie attached) --->|                           |
       |                                           |--- Verify & Rotate Token -|
       |<-- 6. New Access Token -------------------|                           |
Enter fullscreen mode Exit fullscreen mode

Implementation in Node.js (Express)

Below is a simplified, production-grade implementation demonstrating token generation, secure cookie configuration, and refresh token rotation.

Prerequisites

Install the required dependencies:

npm install express jsonwebtoken cookie-parser bcrypt pg
Enter fullscreen mode Exit fullscreen mode

1. Database Schema for Refresh Tokens

Refresh tokens must be tracked in a persistent database to support revocation and token rotation detection.

CREATE TABLE refresh_tokens (
    id SERIAL PRIMARY KEY,
    user_id INT NOT NULL,
    token_hash VARCHAR(255) NOT NULL,
    expires_at TIMESTAMP NOT NULL,
    revoked BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
Enter fullscreen mode Exit fullscreen mode

2. Authentication Controller & Service

const express = require('express');
const jwt = require('jsonwebtoken');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');

const app = express();
app.use(express.json());
app.use(cookieParser());

const ACCESS_SECRET = process.env.ACCESS_SECRET || 'access_secret_key';
const REFRESH_SECRET = process.env.REFRESH_SECRET || 'refresh_secret_key';

// Mock Database
const db = {
    users: [{ id: 1, email: 'user@example.com', passwordHash: '$2b$10$e... (hashed)' }],
    refreshTokens: [] // In production, use PostgreSQL/MongoDB
};

// Helper: Generate Access Token
function generateAccessToken(user) {
    return jwt.sign({ userId: user.id, email: user.email }, ACCESS_SECRET, { expiresIn: '15m' });
}

// Helper: Generate Refresh Token
function generateRefreshToken(user) {
    return jwt.sign({ userId: user.id }, REFRESH_SECRET, { expiresIn: '7d' });
}

// Login Endpoint
app.post('/api/login', async (req, res) => {
    const { email, password } = req.body;
    const user = db.users.find(u => u.email === email);

    if (!user) return res.status(401).json({ error: 'Invalid credentials' });

    // In production, use bcrypt.compare(password, user.passwordHash)
    const isValidPassword = password === 'SecurePassword123!';
    if (!isValidPassword) return res.status(401).json({ error: 'Invalid credentials' });

    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user);

    // Store refresh token in database (store hashed for security)
    db.refreshTokens.push({
        userId: user.id,
        token: refreshToken,
        revoked: false
    });

    // Send refresh token inside an HttpOnly, Secure cookie
    res.cookie('refreshToken', refreshToken, {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict',
        maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
    });

    // Send access token in JSON response body
    return res.json({ accessToken });
});
Enter fullscreen mode Exit fullscreen mode

3. Implementing Refresh Token Rotation

Refresh token rotation invalidates the current refresh token upon use and issues a fresh pair (or a new refresh token). If an already-revoked token is presented, it indicates a token theft attempt, and the system must invalidate all tokens associated with that user.

app.post('/api/refresh', async (req, res) => {
    const cookies = req.cookies;
    if (!cookies?.refreshToken) return res.sendStatus(401);

    const incomingRefreshToken = cookies.refreshToken;

    // Clear cookie immediately to prepare for rotation
    res.clearCookie('refreshToken', {
        httpOnly: true,
        secure: process.env.NODE_ENV === 'production',
        sameSite: 'strict'
    });

    try {
        // Verify signature
        const payload = jwt.verify(incomingRefreshToken, REFRESH_SECRET);

        // Check database
        const tokenRecord = db.refreshTokens.find(t => t.token === incomingRefreshToken);

        if (!tokenRecord || tokenRecord.revoked) {
            // SECURITY ALERT: Reuse of revoked token detected!
            // Revoke all refresh tokens for this user to mitigate session hijacking
            db.refreshTokens
                .filter(t => t.userId === payload.userId)
                .forEach(t => t.revoked = true);

            return res.status(403).json({ error: 'Token reuse detected. All sessions revoked.' });
        }

        // Revoke the old token
        tokenRecord.revoked = true;

        // Fetch user
        const user = db.users.find(u => u.id === payload.userId);
        if (!user) return res.sendStatus(403);

        // Issue new tokens
        const newAccessToken = generateAccessToken(user);
        const newRefreshToken = generateRefreshToken(user);

        db.refreshTokens.push({
            userId: user.id,
            token: newRefreshToken,
            revoked: false
        });

        // Set new refresh token cookie
        res.cookie('refreshToken', newRefreshToken, {
            httpOnly: true,
            secure: process.env.NODE_ENV === 'production',
            sameSite: 'strict',
            maxAge: 7 * 24 * 60 * 60 * 1000
        });

        return res.json({ accessToken: newAccessToken });

    } catch (err) {
        return res.sendStatus(403);
    });
});
Enter fullscreen mode Exit fullscreen mode

Mitigating CSRF with SameSite Attributes

Using HttpOnly cookies introduces vulnerability to Cross-Site Request Forgery (CSRF). If a user visits a malicious site, that site can trigger a request to your API (POST /api/refresh), and the browser will automatically attach the stored cookie.

To defend against this, configure the SameSite attribute:

  • SameSite=Strict: The cookie is never sent in cross-site requests. This provides maximum security but degrades UX if users navigate to your site from an external link and expect to remain logged in.
  • SameSite=Lax: The cookie is withheld on cross-site subrequests (like images or fetch calls), but sent when a user navigates to the origin site (e.g., following a link). This is the modern default for most browsers and balances security with usability.

For high-security applications (like banking or administrative dashboards), SameSite=Strict is recommended.

Protected Route Middleware

For standard API requests, the client includes the access token in the Authorization header using the Bearer schema.

function authenticateToken(req, res, next) {
    const authHeader = req.headers['authorization'];
    const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN

    if (!token) return res.sendStatus(401);

    jwt.verify(token, ACCESS_SECRET, (err, user) => {
        if (err) return res.sendStatus(403);
        req.user = user;
        next();
    });
}

app.get('/api/protected', authenticateToken, (req, res) => {
    res.json({ message: 'Access granted', user: req.user });
});

app.listen(3000, () => console.log('Server running on port 3000'));
Enter fullscreen mode Exit fullscreen mode

Summary Best Practices

  1. Never store access tokens in localStorage. Keep them in application memory (e.g., React state or Redux store).
  2. Store refresh tokens in HttpOnly, Secure, SameSite=Strict/Lax cookies to prevent XSS exfiltration.
  3. Implement Refresh Token Rotation to detect and neutralize token theft.
  4. Keep access token lifespans short (5 to 15 minutes) to limit the damage window if a token is intercepted.

Top comments (0)