JWT Dual Token Pattern: Access + Refresh Token in Production

January 2026 · ~12 min read · Part 2 of the JWT series

In Part 1: How JWT Works we covered what a JWT is and how signatures are verified. In production, however, a single-token design is almost guaranteed to fail. This post walks through the production-grade answer — the Access Token + Refresh Token dual-token pattern — and shows how to reconcile two opposing forces: how to revoke a token and how to never bother the user.

The single-token dilemma

A JWT is, by design, stateless on the server. Once issued, you cannot revoke it. You're stuck relying on the exp claim. But that creates a trap:

  • Short exp (15 min): the user gets kicked out of a long form every quarter hour. Support tickets pile up.
  • Long exp (30 days): a leaked token is valid for a month. You cannot revoke it. Blast radius is huge.

The fix is separation of concerns: one short-lived token for everyday API calls (cheap to lose), and one long-lived token whose only job is to mint new short-lived ones (small attack surface).

The dual-token model

PropertyAccess TokenRefresh Token
Lifetime5–30 minutes7–30 days
PurposeAuthorize API callsOnly mints new access tokens
TransportAuthorization: Bearer …httpOnly cookie (auto-sent)
StorageMemory (best) / sessionStoragehttpOnly + Secure + SameSite cookie

⚠ Never store any token in localStorage — any XSS payload can read it. In-memory is the safest place for the access token; sessionStorage is an acceptable compromise if you must persist across reloads.

The login flow, end to end

  Browser                          Server
    |                                 |
    |--- POST /api/login -------->  |
    |    { email, password }          |--- verify bcrypt
    |                                 |
    |<-- 200 ------------------------|
    |    { access (15m) }             |
    |    Set-Cookie: refresh=...      |  (HttpOnly, Secure, SameSite=Strict, 7d)
    |                                 |
    |--- GET /api/profile ---------> |
    |    Authorization: Bearer <access>|
    |                                 |
    |<-- 200 { user } ---------------|
    |                                 |
    |   … 15 min later …              |
    |                                 |
    |--- GET /api/refresh ---------> |
    |    (cookie sent automatically)  |--- verify refresh, ROTATE
    |                                 |
    |<-- 200 ------------------------|
    |    { access (new) }             |
    |    Set-Cookie: refresh=<new>    |  (old refresh now dead)
    |                                 |
    |   … 7 days later …              |
    |--- GET /api/refresh ---------> |
    |<-- 401 (refresh expired) ------|
    |    → redirect to /login         |

Node.js implementation

Login endpoint

// login.js
const jwt    = require('jsonwebtoken');
const bcrypt = require('bcrypt');

const ACCESS_SECRET  = process.env.ACCESS_SECRET;   // 32+ random bytes
const REFRESH_SECRET = process.env.REFRESH_SECRET;  // DIFFERENT secret

app.post('/api/login', async (req, res) => {
  const user = await User.findOne({ email: req.body.email });
  if (!user || !await bcrypt.compare(req.body.password, user.hash))
    return res.status(401).json({ error: 'Invalid credentials' });

  const access = jwt.sign(
    { sub: user._id, role: user.role },
    ACCESS_SECRET,
    { expiresIn: '15m' }
  );
  const refresh = jwt.sign(
    { sub: user._id },
    REFRESH_SECRET,
    { expiresIn: '7d' }
  );

  res.cookie('refresh', refresh, {
    httpOnly: true,    // JS can't read it  → mitigates XSS
    secure: true,      // HTTPS only         → blocks MITM
    sameSite: 'strict',// not sent on cross-site requests → blocks CSRF
    path: '/api/refresh', // limit scope
    maxAge: 7 * 24 * 60 * 60 * 1000
  });
  res.json({ access, user: { id: user._id, name: user.name } });
});

Refresh Token Rotation (with replay detection)

Definition: every time a refresh token is exchanged, the server issues a new refresh token and the old one becomes invalid immediately.

Why this stops replay: if an attacker steals a refresh token and tries to use it twice — once the legitimate client did, once the attacker does — the second use matches an already-rotated token. The server detects this, assumes the original was compromised, and invalidates the entire token family, forcing the user (and the attacker) to log in again.

// In production use Redis, not an in-memory Set
const usedRefreshTokens = new Set();

app.post('/api/refresh', async (req, res) => {
  const oldRefresh = req.cookies.refresh;
  if (!oldRefresh) return res.status(401).end();

  // Replay detected → kill the entire family
  if (usedRefreshTokens.has(oldRefresh)) {
    usedRefreshTokens.clear();
    res.clearCookie('refresh');
    return res.status(401).json({ error: 'Token reuse detected — session terminated' });
  }

  try {
    const payload = jwt.verify(oldRefresh, REFRESH_SECRET);
    usedRefreshTokens.add(oldRefresh);

    const newAccess = jwt.sign(
      { sub: payload.sub, role: payload.role },
      ACCESS_SECRET,
      { expiresIn: '15m' }
    );
    const newRefresh = jwt.sign(
      { sub: payload.sub },
      REFRESH_SECRET,
      { expiresIn: '7d' }
    );

    res.cookie('refresh', newRefresh, {
      httpOnly: true, secure: true, sameSite: 'strict',
      path: '/api/refresh',
      maxAge: 7 * 24 * 60 * 60 * 1000
    });
    res.json({ access: newAccess });
  } catch (err) {
    res.status(401).end();
  }
});

In production, store usedRefreshTokens in Redis with a TTL equal to the refresh token's lifetime, so old entries don't accumulate forever.

4 revocation strategies compared

StrategyHow it worksVerdict
BlacklistStore revoked jti in Redis, check on every requestSimple, scales horizontally. Adds a Redis lookup per request.
WhitelistDeny by default, only allow tokens in a session storeNegates the point of stateless JWT. Rarely used.
Short exp + rotationAccess auto-expires, refresh is rotatable and revocableRecommended for most apps.
tokenVersionUser row has a tokenVersion int; bump it to invalidate every issued tokenCoarse-grained, but ideal for "log out everywhere" / password change.

Blacklist with jti + Redis

const crypto = require('crypto');
const Redis  = require('ioredis');
const redis  = new Redis(process.env.REDIS_URL);

// Sign with a jti
const access = jwt.sign(
  { sub: user._id },
  ACCESS_SECRET,
  { expiresIn: '15m', jwtid: crypto.randomUUID() }
);

// Explicit revocation (logout / password change)
app.post('/api/logout', async (req, res) => {
  const jti = req.user.jti;
  const exp = req.user.exp;
  const ttl = Math.max(1, exp - Math.floor(Date.now() / 1000));
  await redis.set(`revoked:${jti}`, '1', 'EX', ttl); // auto-cleans at exp
  res.clearCookie('refresh');
  res.status(204).end();
});

// Auth middleware
async function authMiddleware(req, res, next) {
  const token = req.headers.authorization?.slice(7);
  if (!token) return res.status(401).end();

  let payload;
  try { payload = jwt.verify(token, ACCESS_SECRET); }
  catch { return res.status(401).end(); }

  if (await redis.get(`revoked:${payload.jti}`))
    return res.status(401).json({ error: 'Token revoked' });

  req.user = payload;
  next();
}

5 production pitfalls (the ones that actually bite)

  1. Storing the access token in localStorage — a single XSS payload can localStorage.getItem('access') and ship it to an attacker's server. Keep the access token in memory; persist only the httpOnly refresh cookie.
  2. Sending the access token as a cookie — defeats the purpose of the dual-token model: you lose CSRF protection and make rotation awkward. Use Authorization: Bearer for the access token, cookie for the refresh.
  3. Not pinning the algorithms option on verify — historical CVEs (CVE-2015-9235, CVE-2018-0114) hinge on servers accepting alg: none or treating an RS256 public key as an HS256 secret. Always pass { algorithms: ['RS256'] } explicitly.
  4. Reusing one secret for both access and refresh — one leak = total compromise. Use two independent, randomly generated secrets (or one RSA/EC keypair for access, one HMAC secret for refresh).
  5. Skipping rotation — a refresh token with a 30-day lifetime and no rotation is, functionally, a 30-day bearer credential. Pair every refresh exchange with rotation and store the previous token in a one-time-use set.

🛠 Try it yourself

Paste any JWT into the DevToolbox JWT Decoder to see the sub, exp, and jti claims exposed in the payload — exactly the fields you'll be reading server-side in the code above.

FAQ

Q1: Why is the access token short and the refresh token long?

A: It's the security vs. UX trade-off. The access token is sent on every API request — a large attack surface — so it must be short-lived. The refresh token is sent on exactly one endpoint, far less frequently, so it can be long-lived and afford to be heavily protected (rotation, device binding, server-side checks).

Q2: Can I just store refresh tokens in Redis as a whitelist?

A: Yes — and for high-security apps (banking, healthcare) you arguably should. It costs you a Redis lookup per refresh and persistence guarantees (RDB + AOF, or a managed Redis with persistence enabled). For most apps, short TTL + rotation is enough; the whitelist is the next tier up.

Q3: How does the frontend handle silent refresh?

A: An Axios / fetch interceptor catches 401 responses, calls /api/refresh (cookie travels automatically), stores the new access token in memory, and replays the original request. With multiple in-flight 401s, queue them behind a single refresh promise to avoid a thundering herd of refresh calls.

Q4: What if the access token is still in flight when I revoke it?

A: The jti blacklist kills the next request that hits the middleware, which returns 401 and the frontend redirects to /login. Worst-case latency equals the remaining access token lifetime — which is exactly why you keep it short.

Q5: How do I keep the refresh endpoint itself from being abused?

A: Three layers: (1) rate-limit per IP and per userId (e.g. 10/min); (2) bind the refresh token to a fingerprint — hash of User-Agent + a first-party cookie or the IP's /24; mismatch → reject. (3) rotation means a single replay attempt destroys the chain and forces re-authentication, so attackers can't quietly farm new tokens.

Wrapping up

The dual-token pattern is fundamentally about separation of duties: the access token does the risky, high-frequency work and is disposable; the refresh token is the long-lived trust anchor and is heavily guarded. Add rotation (to kill replay), an httpOnly + Secure + SameSite=Strict cookie (to keep the refresh token out of JS and away from cross-site requests), and a jti blacklist (to revoke on demand), and you have a setup that survives a real incident.

Next in the series: OAuth 2.0 + JWT — how JWTs fit into third-party login (Google, GitHub, Auth0) and which claims you actually need to trust.

📚 Continue the series