JWT Authentication in Node.js: Refresh Token Rotation, HttpOnly Cookies and Revocation

Key takeaways

JWT authentication is easy to get wrong. This guide covers the full pattern for Express and Next.js: short-lived access tokens, rotating refresh tokens with reuse detection, HttpOnly cookie storage and CSRF, Redis-based revocation, and an honest comparison with server-side sessions.

JWT Structure

A JWT is three Base64URL-encoded parts joined by dots: Header.Payload.Signature. The header describes the algorithm. The payload carries claims (user ID, role, expiry). The signature is computed over the first two parts with a secret or private key — it’s what prevents anyone from forging or modifying a token.

The property that makes JWTs attractive for APIs: the server doesn’t need to look anything up to validate a token. It just verifies the signature. That makes JWT-based auth stateless and easy to scale horizontally. The trade-off is that a token can’t be invalidated before it expires, which is why short expiry times and refresh token rotation matter so much.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzEzMTY4MDAwLCJleHAiOjE3MTMxNjg5MDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Header.Payload.Signature
// Decode (without verifying — never trust this for authorization!)
JSON.parse(atob('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'))
// Header: { alg: "HS256", typ: "JWT" }

JSON.parse(atob('eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzEzMTY4MDAwLCJleHAiOjE3MTMxNjg5MDB9'))
// Payload: {
//   sub: "user123",    — subject (user ID)
//   role: "admin",     — custom claim
//   iat: 1713168000,   — issued at
//   exp: 1713168900,   — expires at (15 minutes later)
// }

The signature prevents tampering — changing any byte of the payload invalidates it. But anyone can read the payload, so never put passwords, card numbers, or other secrets in it.

Deep dive: JWS, verification, and production claims

A JWT in the wild is almost always a JWS compact serialization (RFC 7515): three Base64URL segments. The middle segment is not encrypted — only Base64URL-encoded — so treat it as public. The third segment is the signature over the exact bytes base64url(header) + "." + base64url(payload) using the algorithm named in alg.

Signature verification (step by step)

  1. Split the token; require exactly two dots and three parts.
  2. Parse the header JSON; allowlist alg (reject none and anything you didn’t expect). Letting the token choose its own algorithm is the root of the classic algorithm-confusion attacks.
  3. For HS256, recompute HMAC-SHA256 with your secret and compare in constant time. For RS256, verify with the issuer’s public key (often fetched via JWKS and cached by kid).
  4. Parse the payload JSON only after the signature checks out.
  5. Enforce exp (and nbf/iat if present).
  6. Enforce aud and iss when the token comes from an external identity provider or is shared across services, so a token minted for another API can’t be replayed against yours.

jsonwebtoken’s jwt.verify does steps 1–5 for you; jwt.decode does none of them.

Claims worth validating Beyond exp, production APIs often require iss (issuer URL), aud (your API identifier), and jti (a unique token ID) for revocation and rotation. Custom claims like role drive authorization after authentication.


Setup

npm install jsonwebtoken redis cookie-parser
npm install -D @types/jsonwebtoken @types/cookie-parser

The Express examples assume app.use(express.json()) and app.use(cookieParser()), and a connected node-redis v4 client exported from lib/redis.ts.


Access Token + Refresh Token Pattern

A single long-lived JWT is the most common JWT security mistake. If that token leaks — through an XSS bug, a log file, or a browser extension — the attacker has access until it expires, and you can’t take it back.

The fix is two tokens with different lifetimes. The access token is short-lived (15 minutes) and sent on every API request. The refresh token is long-lived (7–30 days) and handled more carefully — it’s only sent to the auth endpoints. When the access token expires, the client silently exchanges the refresh token for a new pair.

This limits the damage window: a stolen access token is useless after 15 minutes. Refresh tokens are tracked in Redis, so they can be revoked, and with rotation a used refresh token is immediately invalidated.

// lib/tokens.ts
import jwt from 'jsonwebtoken';
import crypto from 'crypto';

const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;

export interface TokenPayload {
  sub: string;    // user ID
  role: string;
  jti: string;    // JWT ID — unique per token
}

// Short-lived access token (15 minutes)
export function createAccessToken(userId: string, role: string): string {
  return jwt.sign(
    { sub: userId, role, jti: crypto.randomUUID() },
    ACCESS_SECRET,
    { expiresIn: '15m', algorithm: 'HS256' }
  );
}

// Long-lived refresh token (7 days). Returns the jti so the caller can store it.
export function createRefreshToken(userId: string): { token: string; jti: string } {
  const jti = crypto.randomUUID();
  const token = jwt.sign(
    { sub: userId, jti },
    REFRESH_SECRET,
    { expiresIn: '7d', algorithm: 'HS256' }
  );
  return { token, jti };
}

// Pin the algorithm: never let the token header choose it
export function verifyAccessToken(token: string): TokenPayload {
  return jwt.verify(token, ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
}

export function verifyRefreshToken(token: string): TokenPayload {
  return jwt.verify(token, REFRESH_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
}

Separate secrets for access and refresh tokens mean that a refresh token can never be accepted as an access token (or vice versa), even though both are JWTs with a sub claim.

Tracking refresh tokens in Redis

// lib/sessions.ts
import { redis } from './redis';

export const REFRESH_TTL_SECONDS = 7 * 24 * 60 * 60;

export async function storeRefreshToken(userId: string, jti: string) {
  await redis.multi()
    .setEx(`refresh:${jti}`, REFRESH_TTL_SECONDS, userId)
    .sAdd(`user_refresh:${userId}`, jti)             // index of this user's sessions
    .expire(`user_refresh:${userId}`, REFRESH_TTL_SECONDS)
    .exec();
}

// Atomically read-and-delete: a token can be consumed exactly once
export async function consumeRefreshToken(jti: string): Promise<string | null> {
  return redis.getDel(`refresh:${jti}`);   // GETDEL, Redis 6.2+
}

export async function revokeAllRefreshTokens(userId: string) {
  const jtis = await redis.sMembers(`user_refresh:${userId}`);
  if (jtis.length > 0) await redis.del(jtis.map((j) => `refresh:${j}`));
  await redis.del(`user_refresh:${userId}`);
}

consumeRefreshToken uses GETDEL instead of a GET followed by a DEL. With two separate commands, two concurrent requests carrying the same refresh token can both pass the GET before either runs the DEL, and both walk away with a fresh token pair. That breaks rotation in exactly the case it exists for — an attacker racing the legitimate client.


Login — Issue Tokens

// routes/auth.ts
import express from 'express';
import bcrypt from 'bcrypt';
import { createAccessToken, createRefreshToken } from '../lib/tokens';
import { storeRefreshToken, REFRESH_TTL_SECONDS } from '../lib/sessions';
import { db } from '../lib/db';

const router = express.Router();

const baseCookie = {
  httpOnly: true,                                   // not readable from JavaScript
  secure: process.env.NODE_ENV === 'production',    // HTTPS only in production
  sameSite: 'strict' as const,                      // not sent on cross-site requests
};

export function setAuthCookies(res: express.Response, accessToken: string, refreshToken: string) {
  res.cookie('access_token', accessToken, {
    ...baseCookie,
    maxAge: 15 * 60 * 1000,                         // Express uses milliseconds
  });
  res.cookie('refresh_token', refreshToken, {
    ...baseCookie,
    path: '/auth',                                  // only sent to /auth/* (refresh, logout)
    maxAge: REFRESH_TTL_SECONDS * 1000,
  });
}

router.post('/login', async (req, res) => {
  const { email, password } = req.body;

  const user = await db.user.findUnique({ where: { email } });
  if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const accessToken = createAccessToken(user.id, user.role);
  const refresh = createRefreshToken(user.id);
  await storeRefreshToken(user.id, refresh.jti);

  setAuthCookies(res, accessToken, refresh.token);
  res.json({ user: { id: user.id, name: user.name, role: user.role } });
});

The refresh cookie is scoped with path: '/auth', so the browser doesn’t attach it to ordinary API calls — it only travels to the endpoints that need it. Scope it to the whole auth prefix rather than to /auth/refresh alone: if the path is exactly /auth/refresh, the browser won’t send the cookie to /auth/logout, and logout silently fails to revoke anything.

Returning the same Invalid credentials error for “no such user” and “wrong password” is deliberate: different messages let an attacker enumerate which emails have accounts.


Refresh Token Rotation and Reuse Detection

Rotation means each refresh token can be used exactly once. When the client exchanges a refresh token, the server consumes it and issues a new one. The security payoff is reuse detection: a refresh token with a valid signature that is no longer in Redis has either been rotated already or revoked. If it shows up again, either the legitimate client or an attacker is replaying a stolen copy, and the server can’t tell which — so the safe response is to revoke every session for that user and force a fresh login.

Without rotation, a stolen refresh token is valid for its full lifetime (days or weeks), and nothing ever signals the theft.

import { verifyRefreshToken, createAccessToken, createRefreshToken, TokenPayload } from '../lib/tokens';
import { consumeRefreshToken, storeRefreshToken, revokeAllRefreshTokens } from '../lib/sessions';

router.post('/refresh', async (req, res) => {
  const refreshToken = req.cookies.refresh_token;
  if (!refreshToken) {
    return res.status(401).json({ error: 'No refresh token' });
  }

  let payload: TokenPayload;
  try {
    payload = verifyRefreshToken(refreshToken);
  } catch {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }

  // Consume atomically — the old refresh token is dead from this point on
  const userId = await consumeRefreshToken(payload.jti);
  if (!userId) {
    // Valid signature, not expired, but already used or revoked: treat as theft
    await revokeAllRefreshTokens(payload.sub);
    return res.status(401).json({ error: 'Refresh token reuse detected' });
  }

  const user = await db.user.findUnique({ where: { id: payload.sub } });
  if (!user) {
    return res.status(401).json({ error: 'User not found' });
  }

  // Issue a new pair with fresh user data (role changes take effect here)
  const accessToken = createAccessToken(user.id, user.role);
  const refresh = createRefreshToken(user.id);
  await storeRefreshToken(user.id, refresh.jti);

  setAuthCookies(res, accessToken, refresh.token);
  res.json({ user: { id: user.id, name: user.name, role: user.role } });
});

The trap with strict reuse detection is a false positive you will cause yourself. If the browser fires two refreshes at the same moment — two tabs, or several API calls that all got a 401 together — the first consumes the token and the second looks exactly like a replay, so the user gets logged out of every device for no visible reason. Fix this on the client with a single in-flight refresh (see “Client-Side Token Refresh” below). Some teams also add a short grace window, keeping the consumed jti mapped to its replacement for 10–30 seconds. Either way, decide on this before you ship rotation, not after the first support ticket.


Logout — Revoke Tokens

router.post('/logout', async (req, res) => {
  const refreshToken = req.cookies.refresh_token;

  if (refreshToken) {
    try {
      const payload = verifyRefreshToken(refreshToken);
      await consumeRefreshToken(payload.jti);   // revoke this session only
    } catch {
      // Token already invalid or expired — nothing to revoke
    }
  }

  res.clearCookie('access_token');
  res.clearCookie('refresh_token', { path: '/auth' });   // path must match the one used to set it
  res.json({ message: 'Logged out successfully' });
});

Logout deliberately does not require a valid access token. If it did, a user whose access token expired a minute ago couldn’t log out, and the refresh token would stay valid in Redis. For “log out everywhere”, call revokeAllRefreshTokens(userId) instead. The access tokens already issued remain valid until they expire — at most 15 minutes — which is the trade-off you accepted by choosing stateless access tokens. If that window is unacceptable (for example after a password change or account compromise), add a jti denylist or a token-version check on top.

clearCookie only removes a cookie if the path (and domain) match the values it was set with. Forgetting path: '/auth' here leaves the refresh cookie in the browser.


Auth Middleware

// middleware/authenticate.ts
import jwt from 'jsonwebtoken';
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../lib/tokens';

export interface AuthRequest extends Request {
  user?: { id: string; role: string };
}

export function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
  // Cookie for browsers, Authorization header for mobile apps and service clients
  const header = req.headers.authorization;
  const token = req.cookies.access_token
    ?? (header?.startsWith('Bearer ') ? header.slice(7) : undefined);

  if (!token) {
    return res.status(401).json({ error: 'Authentication required' });
  }

  try {
    const payload = verifyAccessToken(token);
    req.user = { id: payload.sub, role: payload.role };
    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' });
  }
}

// Role-based authorization
export function authorize(...roles: string[]) {
  return (req: AuthRequest, res: Response, next: NextFunction) => {
    if (!req.user) return res.status(401).json({ error: 'Unauthenticated' });
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }
    next();
  };
}

// Usage
router.get('/admin/users', authenticate, authorize('admin'), listUsers);
router.get('/profile', authenticate, getProfile);

Keep the status codes consistent: 401 means “who are you?” (missing, invalid, or expired token — the client should refresh or log in) and 403 means “I know who you are, and you can’t do this” (refreshing won’t help). Clients that treat 403 as a trigger to refresh end up in pointless refresh loops on permission errors.

Accepting a Bearer header in addition to the cookie matters for native mobile apps and server-to-server clients, which don’t have a browser cookie jar. They store the tokens in the platform’s secure storage (Keychain, Android Keystore) and send the access token explicitly.


Client-Side Token Refresh

// api/client.ts — refresh on 401 TOKEN_EXPIRED, with a single in-flight refresh
const API_URL = process.env.NEXT_PUBLIC_API_URL;

let refreshInFlight: Promise<boolean> | null = null;

function refreshOnce(): Promise<boolean> {
  // Every caller that hits an expired token awaits the same refresh request
  refreshInFlight ??= fetch(`${API_URL}/auth/refresh`, {
    method: 'POST',
    credentials: 'include',
  })
    .then((r) => r.ok)
    .finally(() => { refreshInFlight = null; });
  return refreshInFlight;
}

export async function apiCall(path: string, options: RequestInit = {}): Promise<Response> {
  const doFetch = () => fetch(`${API_URL}${path}`, { ...options, credentials: 'include' });

  let response = await doFetch();

  if (response.status === 401) {
    const body = await response.clone().json().catch(() => ({}));
    if (body.code === 'TOKEN_EXPIRED') {
      if (await refreshOnce()) {
        response = await doFetch();          // retry once with the new cookie
      } else {
        window.location.href = '/login';
      }
    }
  }

  return response;
}

The shared refreshInFlight promise is what keeps rotation from logging users out: when a page load fires five API calls and all five come back with TOKEN_EXPIRED, only one refresh request goes out and the other four wait for it. Without it, the second through fifth refreshes present a token that was just rotated and trip reuse detection. This handles one tab. Across several tabs you need the server-side grace window mentioned above, or coordination between tabs with the Web Locks API or a BroadcastChannel.

credentials: 'include' makes the browser send cookies on cross-origin requests, but SameSite=Strict cookies are still withheld on cross-site requests. If your frontend is app.example.com and the API is api.example.com, those count as the same site and everything works. If the API lives on a different registrable domain, the cookies won’t be sent. Serve the API from a subdomain or proxy it under the frontend’s origin, rather than weakening the cookie to SameSite=None.


Where to Keep the Tokens: Options and CSRF

There are two reasonable browser setups, and they fail in different ways:

SetupXSS exposureCSRF exposureNotes
Both tokens in HttpOnly cookies (this guide)Tokens can’t be read by scriptsCookies are sent automatically → needs SameSite/CSRF defenseSimplest client code; works with SSR
Access token in JS memory, refresh token in HttpOnly cookieAn XSS can use the in-memory token while the page is openOnly the refresh endpoint is cookie-authenticatedAccess token lost on reload → refresh on startup
Either token in localStorageAny XSS can read and exfiltrate itNoneAvoid for anything sensitive

An XSS bug is bad in every setup — injected script can call your API as the user while the page is open. HttpOnly storage limits the damage to that session. With localStorage, the attacker can copy the token and keep using it from anywhere until it expires.

With cookie-based auth, CSRF is the thing to design for. SameSite=Strict stops the browser from attaching the cookies to requests triggered by other sites, which covers the classic attack. If you have to use SameSite=Lax (for example, so the user stays logged in when they arrive from an external link), or you support older browsers, add a double-submit token:

import crypto from 'crypto';

// On login: issue a readable CSRF cookie alongside the HttpOnly auth cookies
res.cookie('csrf_token', crypto.randomBytes(32).toString('hex'), {
  httpOnly: false,          // the frontend must read it to copy it into a header
  secure: true,
  sameSite: 'strict',
});

// On every state-changing request: header must match cookie
function verifyCsrf(req: Request, res: Response, next: NextFunction) {
  const header = req.headers['x-csrf-token'];
  if (!header || header !== req.cookies.csrf_token) {
    return res.status(403).json({ error: 'Invalid CSRF token' });
  }
  next();
}

The defense works because a cross-site attacker can make the browser send your cookies but can’t read them, so they can’t copy the value into the X-CSRF-Token header. Apply verifyCsrf to POST/PUT/PATCH/DELETE routes, including /auth/refresh and /auth/logout.


Next.js (App Router) Implementation

The same design carries over to Next.js route handlers. Two details trip people up when porting it.

1. Runtime. jsonwebtoken depends on Node’s crypto module and does not run on the Edge runtime. Route handlers default to Node.js, which is fine. middleware.ts runs on the Edge runtime by default, so verification there needs a Web Crypto-based library such as jose.

2. Cookie units. Express’s res.cookie takes maxAge in milliseconds. Next’s cookies.set follows the Set-Cookie header and takes seconds. Copying 15 * 60 * 1000 across gives you a 10-day access-token cookie.

// app/auth/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyRefreshToken, createAccessToken, createRefreshToken } from '@/lib/tokens';
import { consumeRefreshToken, storeRefreshToken, revokeAllRefreshTokens, REFRESH_TTL_SECONDS } from '@/lib/sessions';
import { db } from '@/lib/db';

export const runtime = 'nodejs';   // jsonwebtoken needs Node crypto

const baseCookie = {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'strict' as const,
};

export async function POST(request: NextRequest) {
  const token = request.cookies.get('refresh_token')?.value;
  if (!token) {
    return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
  }

  let payload;
  try {
    payload = verifyRefreshToken(token);
  } catch {
    return NextResponse.json({ error: 'Invalid refresh token' }, { status: 401 });
  }

  const userId = await consumeRefreshToken(payload.jti);
  if (!userId) {
    await revokeAllRefreshTokens(payload.sub);
    return NextResponse.json({ error: 'Refresh token reuse detected' }, { status: 401 });
  }

  const user = await db.user.findUnique({ where: { id: payload.sub } });
  if (!user) {
    return NextResponse.json({ error: 'User not found' }, { status: 401 });
  }

  const accessToken = createAccessToken(user.id, user.role);
  const refresh = createRefreshToken(user.id);
  await storeRefreshToken(user.id, refresh.jti);

  const response = NextResponse.json({ user: { id: user.id, role: user.role } });
  response.cookies.set('access_token', accessToken, { ...baseCookie, maxAge: 15 * 60 });      // seconds
  response.cookies.set('refresh_token', refresh.token, {
    ...baseCookie,
    path: '/auth',
    maxAge: REFRESH_TTL_SECONDS,                                                            // seconds
  });
  return response;
}

The login route follows the same shape: verify the password with bcrypt, call createAccessToken/createRefreshToken/storeRefreshToken, and set both cookies on the NextResponse.

For page-level protection, middleware can gate routes on the access token:

// middleware.ts — Edge runtime: use jose, not jsonwebtoken
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';

const secret = new TextEncoder().encode(process.env.JWT_ACCESS_SECRET);

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('access_token')?.value;
  if (token) {
    try {
      await jwtVerify(token, secret, { algorithms: ['HS256'] });
      return NextResponse.next();
    } catch {
      // expired or invalid — fall through
    }
  }
  const login = new URL('/login', request.url);
  login.searchParams.set('next', request.nextUrl.pathname);
  return NextResponse.redirect(login);
}

export const config = { matcher: ['/dashboard/:path*', '/settings/:path*'] };

Because the refresh cookie is scoped to /auth, middleware on /dashboard never sees it and can’t refresh by itself. That’s intentional. An expired access token sends the user to /login?next=..., and the login page should first try a silent POST /auth/refresh and redirect back if it succeeds, so the user only sees a login form when the refresh token is really gone. Treat middleware as a coarse gate for UX. Route handlers and server actions that return data must still verify the token themselves, because middleware matchers are easy to misconfigure and don’t protect code paths they don’t match.


JWT vs Server-Side Sessions

The refresh-token machinery above — Redis lookups, rotation, revocation — is a partial return to server-side state. That’s worth being honest about when choosing between the two models:

TopicJWT access tokenServer-side session
Where state livesIn the token (client)In a store (Redis/DB), client holds an opaque ID
Per-request costSignature check, no lookupOne store lookup
RevocationHard until expiry — needs denylist or short TTLImmediate: delete the session
Size on the wireHundreds of bytes, grows with claimsA short ID
Multiple servicesEach verifies independently (public key / JWKS)Needs a shared store or a gateway
Mobile / non-browser clientsNatural fit (Authorization header)Works, but cookie handling is clumsier

JWTs fit well when several services or a gateway must authenticate requests without calling back to a central auth service, when you issue tokens to mobile or third-party clients, or when an external identity provider (OAuth/OIDC) issues the tokens anyway.

Sessions fit well for a single web application with one backend, when instant revocation matters (banking, admin panels), or when you’d rather keep all user state server-side. A Redis-backed session is one fast lookup per request, and much of the complexity in this guide simply doesn’t exist.

The decision doesn’t have to be global: a common hybrid uses sessions for the first-party web app and JWTs only for the APIs consumed by mobile apps and other services.


Security Checklist

Token storage:
  ✅ HttpOnly cookies (or access token in memory), never localStorage
  ✅ Secure flag (HTTPS only)
  ✅ SameSite=Strict or Lax, plus CSRF tokens where Lax is used
  ✅ Refresh cookie scoped to the auth path

Token configuration:
  ✅ Short expiry for access tokens (≈15 min)
  ✅ Refresh token rotation with atomic consume (GETDEL)
  ✅ Reuse detection → revoke all sessions for the user
  ✅ Different secrets for access and refresh tokens
  ✅ Algorithms pinned in verify(); 'none' never accepted
  ✅ jti on every token for revocation

Secrets:
  ✅ Strong secrets (≥256 bits, e.g. crypto.randomBytes(32))
  ✅ Loaded from environment / secret manager, not code
  ✅ Different secrets per environment

Common mistakes:
  ❌ Tokens in localStorage (readable by any XSS)
  ❌ Long-lived access tokens (hours/days)
  ❌ No refresh token rotation, or GET-then-DEL rotation
  ❌ Sensitive data in the payload (it's readable)
  ❌ Using jwt.decode() for authorization decisions
  ❌ Logout that requires a valid access token
  ❌ Sharing one HS256 secret across many services