Node.js JWT Authentication With Refresh Token Rotation

A JWT refresh token flow should keep access tokens short-lived without turning the refresh credential into a reusable master key, and I ran the complete Node.js authentication path on Node.js 26.7.0 with rotation, reuse detection, and token-family revocation so you can inspect each state change before adapting it to your own API.

How JWT access tokens and refresh tokens work together

A JSON Web Token (JWT) access token carries claims that an API can verify without a session lookup. Its short lifetime limits how long a stolen access token remains useful, while a refresh token lets an authenticated client request another access token without asking for the password again.

The two credentials need different treatment. The access token travels with protected API requests, while the refresh token stays in an HttpOnly cookie and reaches only the authentication routes.

CredentialJobLifetimeServer-side state
Access tokenAuthorizes protected API requestsShortSignature, issuer, audience, and expiry are verified
Refresh tokenRequests a new access tokenLongerOnly a hash is stored with family, status, and expiry

Rotation makes each refresh token single-use by consuming the presented token, creating its replacement in the same family, and returning a fresh access token.

If a consumed token appears again, the server cannot know whether the client or an attacker sent it. The safe response is to revoke the family and require sign-in again, which follows the replay-detection direction in RFC 9700 section 4.14.

Build the Node.js authentication API

The runnable sample uses Express, cookie-parser, jsonwebtoken, and the Node.js crypto module. It stores refresh-session records in a Map to keep the mechanism visible, then marks the production boundary where that adapter must change.

Create the project

Choose this setup when you are starting from an empty directory because the package script gives the sample one stop condition where npm test must finish with every assertion passing.

npm init -y
npm install express cookie-parser jsonwebtoken
npm pkg set type=module scripts.test="node test.mjs"

The install takes the latest stable packages available to npm at execution time. The type setting lets Node.js load the import syntax used in both source files.

Store refresh sessions and issue tokens

The application accepts valid login credentials, rejects missing or invalid tokens, and records every refresh token as an SHA-256 hash. Its state moves from active to used after one refresh, then to revoked if reuse exposes the family.

Save the following source as app.mjs because login creates the initial family, refresh rotates it, the protected route verifies the access JWT, and logout revokes the family before clearing the cookie.

import crypto from 'node:crypto';
import express from 'express';
import cookieParser from 'cookie-parser';
import jwt from 'jsonwebtoken';

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

const accessSecret = process.env.ACCESS_TOKEN_SECRET ?? 'local-test-access-secret';
const issuer = 'codeforgeek-auth-demo';
const audience = 'codeforgeek-api';
const refreshLifetimeMs = 7 * 24 * 60 * 60 * 1000;
const refreshSessions = new Map();

function hashToken(token) {
  return crypto.createHash('sha256').update(token).digest('hex');
}

function issueAccessToken(userId) {
  return jwt.sign({ sub: userId }, accessSecret, {
    algorithm: 'HS256',
    expiresIn: '15m',
    issuer,
    audience,
  });
}

function issueRefreshToken(userId, familyId = crypto.randomUUID()) {
  const token = crypto.randomBytes(32).toString('base64url');
  refreshSessions.set(hashToken(token), {
    userId,
    familyId,
    status: 'active',
    expiresAt: Date.now() + refreshLifetimeMs,
  });
  return token;
}

function revokeFamily(familyId) {
  for (const session of refreshSessions.values()) {
    if (session.familyId === familyId) session.status = 'revoked';
  }
}

function setRefreshCookie(res, token) {
  res.cookie('refreshToken', token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    path: '/auth',
    maxAge: refreshLifetimeMs,
  });
}

function requireAccessToken(req, res, next) {
  const header = req.get('authorization');
  const token = header?.startsWith('Bearer ') ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: 'access_token_required' });

  try {
    req.auth = jwt.verify(token, accessSecret, {
      algorithms: ['HS256'],
      issuer,
      audience,
    });
    return next();
  } catch {
    return res.status(401).json({ error: 'invalid_access_token' });
  }
}

app.post('/auth/login', (req, res) => {
  const { email, password } = req.body;
  if (email !== '[email protected]' || password !== 'correct-horse') {
    return res.status(401).json({ error: 'invalid_credentials' });
  }

  const userId = 'user-123';
  const refreshToken = issueRefreshToken(userId);
  setRefreshCookie(res, refreshToken);
  return res.json({ accessToken: issueAccessToken(userId) });
});

app.post('/auth/refresh', (req, res) => {
  const presentedToken = req.cookies.refreshToken;
  if (!presentedToken) return res.status(401).json({ error: 'refresh_token_required' });

  const tokenHash = hashToken(presentedToken);
  const session = refreshSessions.get(tokenHash);
  if (!session || session.expiresAt <= Date.now()) {
    return res.status(401).json({ error: 'invalid_refresh_token' });
  }

  if (session.status !== 'active') {
    revokeFamily(session.familyId);
    res.clearCookie('refreshToken', { path: '/auth' });
    return res.status(401).json({ error: 'refresh_token_reuse_detected' });
  }

  session.status = 'used';
  const nextRefreshToken = issueRefreshToken(session.userId, session.familyId);
  setRefreshCookie(res, nextRefreshToken);
  return res.json({ accessToken: issueAccessToken(session.userId) });
});

app.get('/api/profile', requireAccessToken, (req, res) => {
  return res.json({ userId: req.auth.sub, message: 'Protected profile loaded' });
});

app.post('/auth/logout', (req, res) => {
  const token = req.cookies.refreshToken;
  const session = token ? refreshSessions.get(hashToken(token)) : null;
  if (session) revokeFamily(session.familyId);
  res.clearCookie('refreshToken', { path: '/auth' });
  return res.status(204).end();
});

export { app, hashToken, refreshSessions };

The non-obvious choice is making the refresh token opaque instead of signing it as another JWT. The server needs a lookup for rotation and revocation anyway, so a random credential avoids putting session claims in a bearer value and keeps the database record authoritative.

The access-token verifier also fixes the accepted algorithm, issuer, and audience. Signature verification alone is incomplete because a correctly signed token for another service should still be rejected.

Add an integration test

A useful test must prove the failure path, so this one saves the first cookie, rotates it, replays the consumed value, and confirms that the replacement can no longer refresh.

Save this as test.mjs. The server binds to a temporary local port and closes in the finally block, so each run begins with an empty refresh-session store and ends without a leftover process.

import assert from 'node:assert/strict';
import { once } from 'node:events';
import { app, hashToken, refreshSessions } from './app.mjs';

const server = app.listen(0);
await once(server, 'listening');
const baseUrl = `http://127.0.0.1:${server.address().port}`;

async function request(path, options = {}) {
  const response = await fetch(baseUrl + path, options);
  const body = response.status === 204 ? null : await response.json();
  return { response, body };
}

async function login() {
  return request('/auth/login', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ email: '[email protected]', password: 'correct-horse' }),
  });
}

try {
  const rejectedLogin = await request('/auth/login', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ email: '[email protected]', password: 'wrong' }),
  });
  assert.equal(rejectedLogin.response.status, 401);
  assert.equal(rejectedLogin.body.error, 'invalid_credentials');
  console.log('1. Invalid login credentials were rejected');

  const missingAccess = await request('/api/profile');
  assert.equal(missingAccess.response.status, 401);
  assert.equal(missingAccess.body.error, 'access_token_required');
  const invalidAccess = await request('/api/profile', {
    headers: { authorization: 'Bearer malformed' },
  });
  assert.equal(invalidAccess.body.error, 'invalid_access_token');
  console.log('2. Missing and invalid access tokens were rejected');

  const missingRefresh = await request('/auth/refresh', { method: 'POST' });
  assert.equal(missingRefresh.body.error, 'refresh_token_required');
  const unknownRefresh = await request('/auth/refresh', {
    method: 'POST',
    headers: { cookie: 'refreshToken=unknown' },
  });
  assert.equal(unknownRefresh.body.error, 'invalid_refresh_token');
  console.log('3. Missing and unknown refresh tokens were rejected');

  const firstLogin = await login();
  assert.equal(firstLogin.response.status, 200);
  const firstCookie = firstLogin.response.headers.get('set-cookie').split(';')[0];
  console.log('4. Login issued an access token and an HttpOnly refresh cookie');

  const profile = await request('/api/profile', {
    headers: { authorization: `Bearer ${firstLogin.body.accessToken}` },
  });
  assert.equal(profile.response.status, 200);
  assert.equal(profile.body.userId, 'user-123');
  console.log('5. The access token opened the protected profile route');

  const refresh = await request('/auth/refresh', {
    method: 'POST',
    headers: { cookie: firstCookie },
  });
  assert.equal(refresh.response.status, 200);
  const secondCookie = refresh.response.headers.get('set-cookie').split(';')[0];
  assert.notEqual(secondCookie, firstCookie);
  console.log('6. Refresh rotation returned a new access token and refresh cookie');

  const reuse = await request('/auth/refresh', {
    method: 'POST',
    headers: { cookie: firstCookie },
  });
  assert.equal(reuse.body.error, 'refresh_token_reuse_detected');
  const revoked = await request('/auth/refresh', {
    method: 'POST',
    headers: { cookie: secondCookie },
  });
  assert.equal(revoked.body.error, 'refresh_token_reuse_detected');
  console.log('7. Reuse detection revoked the remaining token family');

  const expiringLogin = await login();
  const expiringCookie = expiringLogin.response.headers.get('set-cookie').split(';')[0];
  const expiringToken = expiringCookie.slice('refreshToken='.length);
  refreshSessions.get(hashToken(expiringToken)).expiresAt = Date.now() - 1;
  const expired = await request('/auth/refresh', {
    method: 'POST',
    headers: { cookie: expiringCookie },
  });
  assert.equal(expired.body.error, 'invalid_refresh_token');
  console.log('8. An expired refresh-session record was rejected');

  const logoutLogin = await login();
  const logoutCookie = logoutLogin.response.headers.get('set-cookie').split(';')[0];
  const logout = await request('/auth/logout', {
    method: 'POST',
    headers: { cookie: logoutCookie },
  });
  assert.equal(logout.response.status, 204);
  const afterLogout = await request('/auth/refresh', {
    method: 'POST',
    headers: { cookie: logoutCookie },
  });
  assert.equal(afterLogout.body.error, 'refresh_token_reuse_detected');
  console.log('9. Logout revoked the refresh-token family');
  console.log('All authentication checks passed');
} finally {
  server.close();
}

The opening checks reject bad credentials and missing tokens before the test enters the accepted login, profile, and rotation path. Reuse, expiry, and logout then prove that each refresh family stops working at the intended boundary.

Test refresh token rotation and reuse detection

Run the test from the project directory and stop if any assertion fails because a partial pass does not prove that replay revocation works.

npm test
Terminal output from the Node.js JWT refresh token test showing login, validation, rotation, reuse detection, expiry, and logout checks
The executed npm test run verifies nine accepted and rejected authentication paths, including refresh token rotation and token-family revocation.

The executed output shows nine checks and ends with “All authentication checks passed.” No token values appear in the terminal, which matters because logs often outlive the sessions they describe.

As the next exercise, send two refresh requests with the same cookie at the same time. Your durable-store adapter should allow one atomic consume-and-rotate operation and reject the other as reuse.

Security boundaries before deployment

The sample teaches the state machine, but deployment adds shared storage, transport controls, request protections, and operational revocation. Each boundary changes whether an attacker can reuse a stolen credential or whether two application instances can disagree about session state.

Store refresh sessions durably

Replace the Map with a database or Redis before running more than one process. The consume and replacement insert must be atomic, otherwise concurrent requests can both treat the same token as active.

Store only a one-way hash, the user identifier, family identifier, status, expiry, creation time, and useful device context while enforcing inactivity expiry and a maximum family lifetime so an abandoned session cannot renew forever.

Protect cookies and requests

Use Secure and HttpOnly on the refresh cookie in production, restrict its Path to the authentication endpoints, and choose SameSite based on the client architecture. Cross-site cookie use also needs explicit cross-site request forgery protection because SameSite=None permits cross-site delivery.

Keep access tokens out of URLs and send every credential over Transport Layer Security (TLS). Never print access tokens, refresh tokens, cookies, or signing secrets in application logs.

Revoke sessions deliberately

Logout should revoke the server-side family before clearing the browser cookie, while password changes, account recovery, suspicious reuse, and administrator actions should revoke affected families as well.

Rotate signing secrets through a managed secret store and plan for key identifiers if you operate several active keys. The jsonwebtoken documentation covers the supported signing and verification options, while your key policy decides which algorithms and claims the API accepts.

Common refresh-token failures

Most broken implementations fail at a state transition or storage boundary, so use the response behavior below to keep clients predictable without revealing credential details.

FailureServer responseRequired action
Access token expired401 invalid_access_tokenClient calls the refresh endpoint once
Refresh token missing or unknown401 refresh_token_required or invalid_refresh_tokenClient returns to sign-in
Consumed refresh token reused401 refresh_token_reuse_detectedServer revokes the family and client signs in again
Refresh record expired401 invalid_refresh_tokenServer removes stale records and client signs in again
Concurrent refresh requestsOne succeeds and one is rejectedClient serializes refresh work and server uses an atomic write

Do not retry refresh requests in an unbounded loop. A single refresh attempt can recover an expired access token, but a rejected refresh credential ends the session.

Frequently asked questions

These answers separate token format from session behavior, which is where many implementations become confusing.

Should a refresh token be a JWT?

It can be, but it does not need to be. An opaque random token with a hashed server-side record is easier to revoke and rotate because the session store remains authoritative.

Where should a Node.js app store a refresh token?

For a browser client, use a Secure, HttpOnly cookie with a narrow Path and an appropriate SameSite setting. Native clients should use the operating system secure credential store.

How long should access and refresh tokens last?

Use a short access-token lifetime and a longer but bounded refresh-session lifetime. Choose exact values from the application risk, device type, and reauthentication cost rather than copying one duration into every service.

Why revoke the whole token family after reuse?

A reused token proves that two parties hold credentials from the same family, but the server cannot identify the trusted party. Family revocation stops both and requires a new authenticated session.

Can I store refresh tokens only in process memory?

Only for a local demonstration or a single disposable process. Deployed services need shared durable storage so rotation and revocation survive restarts and stay consistent across instances.

Next step

Replace the in-memory refresh-session adapter with one atomic database transaction, then rerun the rotation, replay, logout, restart, and concurrent-request tests. If you need the access-token foundation first, read the Node.js token authentication tutorial before adding refresh sessions.

Pankaj Kumar
Pankaj Kumar

Pankaj Kumar is the founder and CEO of CodeForGeek, with more than 14 years in IT. He is an open-source enthusiast who enjoys sharing what he learns through CodeForGeek and YouTube, with a focus on Python, data analytics, machine learning, Angular, Node.js, and Kafka.

Articles: 335