DEV Community

Mushood
Mushood

Posted on

JWT Authentication in Express That You Can Actually Revoke

Access tokens, refresh token rotation, and theft detection: the parts most Node.js tutorials leave out.


A friend messaged me about his side project a few months ago:

"Someone else is logged into my account. I changed my password. They're still in."

He had followed the tutorials to the letter. Sign a JWT on login, send it to the frontend, keep it in localStorage, attach it to every request. Done.

What none of those tutorials mentioned is that this setup has no way to un-log anyone in. A JWT is a signed piece of paper. Once you hand it over, it stays valid until it expires, and his expired in 30 days. Changing the password accomplished nothing, because the token had already been signed and nothing about it depended on the password. There was no list of active sessions to delete from. There was nothing to revoke.

His only remaining move was rotating the signing secret, which logged out every user on the platform at once. That was his entire kill switch: burn it all down.

This is the walkthrough I wish someone had handed me the first time I built auth. Token design, storage, refresh rotation, theft detection, the Express code, the Axios interceptor on the frontend, and the specific mistakes that turn a working login into an incident.

It's long. Auth is one of those areas where the missing ten percent is the part that gets you.


What the standard tutorial leaves out

Nearly every "JWT authentication in Node.js" post ends in the same place: sign a token, put it in localStorage, send a Bearer header. That gets you a demo. Four things stand between that and production.

localStorage is readable by any JavaScript on the page. That includes the analytics snippet you added last week, the npm package that got compromised upstream, and any XSS hole in your own code. One call to localStorage.getItem('token') and an attacker holds a working credential they can replay from their own machine. You can't detect it and you can't stop it.

There is no revocation. The appeal of JWTs is stateless verification: the server checks a signature and trusts the payload without touching the database. The price is that you can't take a token back. Ban a user and they stay logged in. Reset a password and the thief stays logged in.

Long expiry makes both of those catastrophic. Developers reach for a long TTL because nobody wants to be logged out every fifteen minutes. But long expiry is exactly what makes a stolen token worth stealing. You end up choosing between bad UX and a bad breach.

The payload isn't encrypted. base64url is encoding. Paste any JWT into jwt.io and read it. I've seen internal role hierarchies, email addresses, and on one occasion a live database connection string sitting in a token payload.

One architecture fixes all four: a very short-lived access token held in memory, plus a long-lived refresh token stored in an httpOnly cookie and tracked in your database.


The architecture

                    LOGIN
                      |
        +-------------+--------------+
        |                            |
  Access Token                 Refresh Token
  (JWT, 15 min)                (opaque, 30 days)
        |                            |
  Held in JS memory            httpOnly cookie
  Sent as:                     Sent automatically
  Authorization: Bearer ...    only to /auth/*
        |                            |
        v                            v
  Verified by signature        Looked up in DB,
  No DB call. Fast.            hashed, rotated
                               on every use
Enter fullscreen mode Exit fullscreen mode

Two tokens doing two very different jobs.

The access token is a real JWT. Short-lived, verified purely by signature, never touches the database. That's what keeps your API fast. If it leaks, your exposure is fifteen minutes.

The refresh token is deliberately not a JWT. It's 64 random bytes. It lives in an httpOnly cookie so JavaScript can't read it, it's stored hashed so a database leak doesn't hand over live sessions, and every time it's used it gets replaced.

That last part, rotation, is what gives you theft detection. It's also the piece almost nobody implements, and it's the heart of Step 6.


Step 1: Setup and real secrets

mkdir jwt-auth-api && cd jwt-auth-api
npm init -y
npm install express jsonwebtoken bcrypt cookie-parser pg dotenv
npm install helmet express-rate-limit cors
npm install -D nodemon
Enter fullscreen mode Exit fullscreen mode

Add "type": "module" to package.json so ESM imports work.

src/
  config/env.js
  db/index.js
  db/refreshTokens.js
  middleware/requireAuth.js
  routes/auth.js
  utils/tokens.js
  utils/cookies.js
  server.js
Enter fullscreen mode Exit fullscreen mode

Now the thirty-second step people skip, which is how signing secrets end up being the literal string secret. For HS256 you want at least 256 bits of randomness:

node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
Enter fullscreen mode Exit fullscreen mode

Run it twice. You need two secrets and they must not be the same value.

# .env
NODE_ENV=development
PORT=5000

DATABASE_URL=postgres://user:pass@localhost:5432/myapp

JWT_ACCESS_SECRET=<first generated value>
JWT_REFRESH_PEPPER=<second generated value>

TOKEN_ISSUER=api.your-domain.com
TOKEN_AUDIENCE=your-domain.com
COOKIE_DOMAIN=.your-domain.com
Enter fullscreen mode Exit fullscreen mode

If you've read my post on environment variables in Vite, the rules here are stricter. These values are backend-only. Never prefix them with VITE_, never let them reach a browser bundle, and put .env in .gitignore before you write a line of code.

A small config module means a missing variable fails at boot rather than at 3am:

// src/config/env.js
import 'dotenv/config';

function required(name) {
  const value = process.env[name];
  if (!value) throw new Error(`Missing required env variable: ${name}`);
  return value;
}

export const config = {
  isProd: process.env.NODE_ENV === 'production',
  port: process.env.PORT || 5000,
  accessSecret: required('JWT_ACCESS_SECRET'),
  refreshPepper: required('JWT_REFRESH_PEPPER'),
  issuer: required('TOKEN_ISSUER'),
  audience: required('TOKEN_AUDIENCE'),
  cookieDomain: process.env.COOKIE_DOMAIN,
  accessTtl: '15m',
  refreshTtlDays: 30,
};
Enter fullscreen mode Exit fullscreen mode

Step 2: Signing and verifying tokens

// src/utils/tokens.js
import jwt from 'jsonwebtoken';
import crypto from 'node:crypto';
import { config } from '../config/env.js';

export function signAccessToken(user) {
  return jwt.sign(
    {
      sub: String(user.id),
      role: user.role,
      // Nothing sensitive. This payload is readable by anyone.
    },
    config.accessSecret,
    {
      algorithm: 'HS256',
      expiresIn: config.accessTtl,
      issuer: config.issuer,
      audience: config.audience,
      jwtid: crypto.randomUUID(),
    }
  );
}

export function verifyAccessToken(token) {
  return jwt.verify(token, config.accessSecret, {
    algorithms: ['HS256'],        // <- do not skip this line
    issuer: config.issuer,
    audience: config.audience,
  });
}
Enter fullscreen mode Exit fullscreen mode

The algorithms: ['HS256'] line is not decoration.

Leave it out and you've told the library to accept whatever algorithm the token's own header claims. An attacker sets {"alg": "none"} and submits an unsigned token; older library versions accepted exactly that. It gets worse if you later migrate to RS256, where an attacker can take your public key, which is public by definition, and use it as an HMAC secret to sign tokens your server then verifies as valid.

The server decides the algorithm. Never the token.

The refresh token is a different animal:

export function generateRefreshToken() {
  return crypto.randomBytes(64).toString('base64url');
}

export function hashRefreshToken(token) {
  return crypto
    .createHmac('sha256', config.refreshPepper)
    .update(token)
    .digest('hex');
}
Enter fullscreen mode Exit fullscreen mode

Two questions come up here every time.

Why isn't the refresh token a JWT? Because you're going to look it up in the database on every use anyway. That lookup is the entire point of it being revocable. A self-describing signed token buys you nothing and adds surface area to get wrong. 512 bits of randomness is simpler and unguessable.

Why HMAC-SHA256 instead of bcrypt? bcrypt is slow on purpose, to protect low-entropy secrets like human passwords from brute force. A 512-bit random token has no brute-force risk; nobody is guessing it. What you actually need is a fast one-way transform so a leaked database dump contains no usable sessions. HMAC with a server-side pepper does that and is cheap enough to run on every request. bcrypt also silently truncates input past 72 bytes, which would quietly weaken the token you just generated.


Step 3: The refresh token table

CREATE TABLE users (
  id            BIGSERIAL PRIMARY KEY,
  email         CITEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,
  role          TEXT NOT NULL DEFAULT 'user',
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE refresh_tokens (
  id          BIGSERIAL PRIMARY KEY,
  user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  token_hash  CHAR(64) NOT NULL UNIQUE,
  family_id   UUID NOT NULL,
  expires_at  TIMESTAMPTZ NOT NULL,
  revoked_at  TIMESTAMPTZ,
  replaced_by CHAR(64),
  user_agent  TEXT,
  ip          INET,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_rt_user   ON refresh_tokens(user_id);
CREATE INDEX idx_rt_family ON refresh_tokens(family_id);
CREATE INDEX idx_rt_expiry ON refresh_tokens(expires_at);
Enter fullscreen mode Exit fullscreen mode

CITEXT needs CREATE EXTENSION IF NOT EXISTS citext; first. Use TEXT with a lowercase-on-write rule if you'd rather not add the extension.

Three columns carry most of the design, so they're worth explaining before the code.

family_id groups every token descended from one login. Laptop login is family A; each refresh in that session keeps family_id = A. Phone login is family B. This lets you kill one compromised session without logging the user out of everything, and when theft is detected, kill an entire chain at once.

revoked_at marks a token as spent. Rotation makes refresh tokens single-use: the moment one is exchanged, it's dead.

replaced_by records which token superseded it. Handy for debugging, and load-bearing for the race condition in Step 6.

The repository:

// src/db/refreshTokens.js
import crypto from 'node:crypto';
import { pool } from './index.js';
import { config } from '../config/env.js';

export async function createRefreshToken({
  userId, tokenHash, familyId, userAgent, ip,
}) {
  const expiresAt = new Date(
    Date.now() + config.refreshTtlDays * 24 * 60 * 60 * 1000
  );
  const { rows } = await pool.query(
    `INSERT INTO refresh_tokens
       (user_id, token_hash, family_id, expires_at, user_agent, ip)
     VALUES ($1, $2, $3, $4, $5, $6)
     RETURNING *`,
    [userId, tokenHash, familyId ?? crypto.randomUUID(), expiresAt, userAgent, ip]
  );
  return rows[0];
}

export async function findByHash(tokenHash) {
  const { rows } = await pool.query(
    `SELECT * FROM refresh_tokens WHERE token_hash = $1`,
    [tokenHash]
  );
  return rows[0] ?? null;
}

export async function revokeToken(id, replacedByHash = null) {
  await pool.query(
    `UPDATE refresh_tokens
        SET revoked_at = NOW(), replaced_by = $2
      WHERE id = $1 AND revoked_at IS NULL`,
    [id, replacedByHash]
  );
}

export async function revokeFamily(familyId) {
  await pool.query(
    `UPDATE refresh_tokens
        SET revoked_at = NOW()
      WHERE family_id = $1 AND revoked_at IS NULL`,
    [familyId]
  );
}

export async function revokeAllForUser(userId) {
  await pool.query(
    `UPDATE refresh_tokens
        SET revoked_at = NOW()
      WHERE user_id = $1 AND revoked_at IS NULL`,
    [userId]
  );
}

export async function deleteExpired() {
  const { rowCount } = await pool.query(
    `DELETE FROM refresh_tokens
      WHERE expires_at < NOW() - INTERVAL '7 days'`
  );
  return rowCount;
}
Enter fullscreen mode Exit fullscreen mode

Run deleteExpired() on a daily cron. This table grows fast and nobody notices until it's twelve million rows.

On Prisma or Mongoose? Same columns, same six operations. Only the query syntax changes.


Step 4: Cookie settings that matter

Small file. A surprising number of auth bugs live in it.

// src/utils/cookies.js
import { config } from '../config/env.js';

export const REFRESH_COOKIE = 'rt';

export function refreshCookieOptions() {
  return {
    httpOnly: true,                 // JavaScript cannot read it
    secure: config.isProd,          // HTTPS only in production
    sameSite: 'strict',             // not sent on cross-site requests
    path: '/auth',                  // only sent to /auth/* endpoints
    domain: config.isProd ? config.cookieDomain : undefined,
    maxAge: config.refreshTtlDays * 24 * 60 * 60 * 1000,
  };
}
Enter fullscreen mode Exit fullscreen mode

Every option earns its place.

  • httpOnly is the entire reason we're using a cookie. XSS can still make requests as the user, but it can't lift the credential and replay it from somewhere else next month.
  • secure keeps the cookie off plain HTTP. It's disabled in development so localhost works, though if you'd rather run HTTPS locally and match production, I covered that in setting up mkcert with Express.
  • sameSite: 'strict' is your CSRF defence. The browser won't attach the cookie to requests that originate on another site.
  • path: '/auth' keeps the cookie off your regular API calls entirely. It goes out only when you're refreshing. Less exposure, smaller headers.

The cross-site gotcha. sameSite: 'strict' works when frontend and API share a registrable domain, so app.example.com calling api.example.com is fine. But a frontend on myapp.vercel.app calling api.mycompany.com is genuinely cross-site, and the cookie simply won't be sent. Your refresh fails with no error message, which is a deeply annoying afternoon.

The workaround is sameSite: 'none' with secure: true, and since that disables your CSRF protection you then need a CSRF token or an Origin check on the refresh endpoint. My advice: put the API on a subdomain of your frontend's domain and skip the whole problem.


Step 5: Register and login

// src/routes/auth.js
import express from 'express';
import bcrypt from 'bcrypt';
import crypto from 'node:crypto';
import rateLimit from 'express-rate-limit';
import { pool } from '../db/index.js';
import { config } from '../config/env.js';
import * as tokenStore from '../db/refreshTokens.js';
import { requireAuth } from '../middleware/requireAuth.js';
import {
  signAccessToken, generateRefreshToken, hashRefreshToken,
} from '../utils/tokens.js';
import { REFRESH_COOKIE, refreshCookieOptions } from '../utils/cookies.js';

const router = express.Router();

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many attempts. Try again later.' },
});

// A valid bcrypt hash of a random string, used to equalise response
// timing when the email doesn't exist.
const DUMMY_HASH =
  '$2b$12$C6UzMDM.H6dfI/f/IKcEe.7qJ8gxvKk1z4qYyWPq7Xo5nJ8VtQ4Ky';

async function issueSession(res, user, req, familyId = null) {
  const refreshToken = generateRefreshToken();
  const tokenHash = hashRefreshToken(refreshToken);

  await tokenStore.createRefreshToken({
    userId: user.id,
    tokenHash,
    familyId,
    userAgent: req.get('user-agent') ?? null,
    ip: req.ip,
  });

  res.cookie(REFRESH_COOKIE, refreshToken, refreshCookieOptions());

  return signAccessToken(user);
}
Enter fullscreen mode Exit fullscreen mode

Register

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

  if (!email || !password) {
    return res.status(400).json({ error: 'Email and password are required' });
  }
  if (password.length < 12) {
    return res.status(400).json({ error: 'Password must be at least 12 characters' });
  }

  const passwordHash = await bcrypt.hash(password, 12);

  try {
    const { rows } = await pool.query(
      `INSERT INTO users (email, password_hash)
       VALUES ($1, $2)
       RETURNING id, email, role`,
      [email.toLowerCase().trim(), passwordHash]
    );
    const user = rows[0];
    const accessToken = await issueSession(res, user, req);

    return res.status(201).json({
      accessToken,
      user: { id: user.id, email: user.email, role: user.role },
    });
  } catch (err) {
    if (err.code === '23505') {
      // Unique violation. Don't confirm the email exists.
      return res.status(409).json({ error: 'Could not create account' });
    }
    throw err;
  }
});
Enter fullscreen mode Exit fullscreen mode

Note the length minimum and the absence of composition rules. "At least one uppercase, one number, one symbol" reliably produces Password1! and very little else. Length is what actually helps.

Login

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

  if (!email || !password) {
    return res.status(400).json({ error: 'Email and password are required' });
  }

  const { rows } = await pool.query(
    `SELECT id, email, role, password_hash FROM users WHERE email = $1`,
    [email.toLowerCase().trim()]
  );
  const user = rows[0];

  // Always run a comparison, even when the user doesn't exist,
  // so response time doesn't leak which emails are registered.
  const valid = await bcrypt.compare(password, user?.password_hash ?? DUMMY_HASH);

  if (!user || !valid) {
    return res.status(401).json({ error: 'Invalid email or password' });
  }

  const accessToken = await issueSession(res, user, req);

  return res.json({
    accessToken,
    user: { id: user.id, email: user.email, role: user.role },
  });
});
Enter fullscreen mode Exit fullscreen mode

Two choices there are deliberate.

The error message is identical for "no such email" and "wrong password." Return "user not found" and you've built an account enumeration endpoint: an attacker walks a list of emails and learns exactly who has an account with you. Pair that with a breach dump from another site and you've handed over a credential-stuffing target list.

The dummy hash matters just as much. Without it, a nonexistent email returns almost immediately while a real one takes as long as bcrypt takes, which at cost factor 12 is a very visible difference. That gap is measurable over a network and leaks precisely the information your generic error message was trying to hide.

Also notice the access token goes in the JSON body, not a cookie. The frontend keeps it in memory, which we'll get to shortly.


Step 6: Refresh, rotation, and reuse detection

This is the important one. Everything else here is fairly standard. This is the part that turns "JWT auth" into something you can defend.

router.post('/refresh', async (req, res) => {
  const presented = req.cookies?.[REFRESH_COOKIE];

  if (!presented) {
    return res.status(401).json({ error: 'Not authenticated', code: 'NO_REFRESH_TOKEN' });
  }

  const presentedHash = hashRefreshToken(presented);
  const stored = await tokenStore.findByHash(presentedHash);

  // Unknown token: forged, or from a family we already purged.
  if (!stored) {
    res.clearCookie(REFRESH_COOKIE, refreshCookieOptions());
    return res.status(401).json({ error: 'Invalid session', code: 'INVALID_REFRESH_TOKEN' });
  }

  // ---- REUSE DETECTION ----
  // This token was already spent. Someone is replaying it.
  if (stored.revoked_at) {
    await tokenStore.revokeFamily(stored.family_id);
    res.clearCookie(REFRESH_COOKIE, refreshCookieOptions());

    console.warn('[security] refresh token reuse detected', {
      userId: stored.user_id,
      familyId: stored.family_id,
      ip: req.ip,
      userAgent: req.get('user-agent'),
    });

    return res.status(401).json({ error: 'Session revoked', code: 'TOKEN_REUSE' });
  }

  if (new Date(stored.expires_at) < new Date()) {
    res.clearCookie(REFRESH_COOKIE, refreshCookieOptions());
    return res.status(401).json({ error: 'Session expired', code: 'REFRESH_EXPIRED' });
  }

  const { rows } = await pool.query(
    `SELECT id, email, role FROM users WHERE id = $1`,
    [stored.user_id]
  );
  const user = rows[0];

  if (!user) {
    await tokenStore.revokeFamily(stored.family_id);
    res.clearCookie(REFRESH_COOKIE, refreshCookieOptions());
    return res.status(401).json({ error: 'Invalid session', code: 'USER_GONE' });
  }

  // ---- ROTATE ----
  const newRefreshToken = generateRefreshToken();
  const newHash = hashRefreshToken(newRefreshToken);

  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    await client.query(
      `UPDATE refresh_tokens
          SET revoked_at = NOW(), replaced_by = $2
        WHERE id = $1 AND revoked_at IS NULL`,
      [stored.id, newHash]
    );
    await client.query(
      `INSERT INTO refresh_tokens
         (user_id, token_hash, family_id, expires_at, user_agent, ip)
       VALUES ($1, $2, $3, $4, $5, $6)`,
      [
        user.id,
        newHash,
        stored.family_id,
        new Date(Date.now() + config.refreshTtlDays * 86400_000),
        req.get('user-agent') ?? null,
        req.ip,
      ]
    );
    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }

  res.cookie(REFRESH_COOKIE, newRefreshToken, refreshCookieOptions());

  return res.json({
    accessToken: signAccessToken(user),
    user: { id: user.id, email: user.email, role: user.role },
  });
});
Enter fullscreen mode Exit fullscreen mode

Why reuse detection actually works

This deserves spelling out, because when I first read about rotation I understood the mechanic and completely missed the point.

Refresh tokens are single-use. Use one, it dies, you get a new one. So a token can only ever be presented twice if two parties are holding the same token, and that only happens if one of them stole it.

Play out the theft:

  1. Attacker obtains refresh token R1 somehow. Malware, a leaked log, a shared machine.
  2. Attacker calls /auth/refresh with R1. It works. They get R2 and an access token. So far you've lost.
  3. Fifteen minutes later your real user's access token expires. Their browser calls /auth/refresh with R1, the only token it has.
  4. R1 is already revoked. Detected.
  5. The whole family dies. The attacker's R2, R3, everything downstream. Both parties get logged out.

Reverse the order, with the real user refreshing first and the attacker replaying later, and detection fires just the same. Either way you catch the theft within one refresh cycle instead of thirty days later, and you catch it automatically, with nobody having to report anything.

That is the difference between my friend's situation and a system that defends itself.

The race condition nobody warns you about

Ship the code above and eventually a user emails you saying they got randomly logged out.

Here's what happened. Three tabs open. All three woke at once, all three fired requests carrying the same expired access token, all three got a 401, all three called /auth/refresh with the same R1. The first won. The other two tripped reuse detection and killed the family.

You've built a system that logs people out for using tabs.

There are two fixes and you want both.

Client side, the real one: deduplicate refresh calls so only one is ever in flight. Code for that is in Step 8.

Server side, the safety net: allow a short grace window. If a revoked token was replaced a couple of seconds ago, that's almost certainly a race and not an attacker, so hand back the replacement instead of burning the session.

const GRACE_MS = 10_000;

if (stored.revoked_at) {
  const age = Date.now() - new Date(stored.revoked_at).getTime();
  const replacement = stored.replaced_by
    ? await tokenStore.findByHash(stored.replaced_by)
    : null;

  const isLikelyRace =
    age < GRACE_MS && replacement && !replacement.revoked_at;

  if (!isLikelyRace) {
    await tokenStore.revokeFamily(stored.family_id);
    res.clearCookie(REFRESH_COOKIE, refreshCookieOptions());
    return res.status(401).json({ error: 'Session revoked', code: 'TOKEN_REUSE' });
  }

  // Concurrent refresh. Reissue an access token against the live
  // replacement without rotating again.
  const { rows } = await pool.query(
    `SELECT id, email, role FROM users WHERE id = $1`, [stored.user_id]
  );
  return res.json({ accessToken: signAccessToken(rows[0]) });
}
Enter fullscreen mode Exit fullscreen mode

Ten seconds is a genuine trade-off rather than a free win. A fast attacker inside that window slips through. But logging out legitimate users daily is worse than a ten-second gap in a detection mechanism most systems don't have at all. Tighten it if your threat model demands it.


Step 7: Middleware, and the authorization bug everyone ships

// src/middleware/requireAuth.js
import { verifyAccessToken } from '../utils/tokens.js';

export function requireAuth(req, res, next) {
  const header = req.headers.authorization;

  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Not authenticated', code: 'NO_TOKEN' });
  }

  try {
    const payload = verifyAccessToken(header.slice(7));
    req.user = { id: payload.sub, role: payload.role };
    return next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      // Distinct code: the client should refresh, not log out.
      return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
    }
    return res.status(401).json({ error: 'Invalid token', code: 'INVALID_TOKEN' });
  }
}

export function requireRole(...roles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Not authenticated' });
    }
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    return next();
  };
}
Enter fullscreen mode Exit fullscreen mode

That separate TOKEN_EXPIRED code looks trivial and isn't. Without it your frontend sees a generic 401 and can't distinguish "your token aged out, refresh quietly" from "your token is garbage, log out." Get it wrong and you either log people out constantly or send them into an infinite refresh loop.

Using it:

router.get('/me', requireAuth, async (req, res) => {
  const { rows } = await pool.query(
    `SELECT id, email, role, created_at FROM users WHERE id = $1`,
    [req.user.id]     // <- from the verified token. Never from req.body.
  );
  res.json(rows[0]);
});

router.delete('/admin/users/:id', requireAuth, requireRole('admin'), handler);
Enter fullscreen mode Exit fullscreen mode

That comment deserves its own paragraph. The most common authorization bug I've found in code review looks like this:

// Broken. Anyone can pass any userId.
router.get('/orders', requireAuth, async (req, res) => {
  const orders = await db.orders.findByUser(req.query.userId);
  res.json(orders);
});
Enter fullscreen mode Exit fullscreen mode

The request was authenticated. It was never authorized. The user proved who they were, then told you whose data to fetch, and you believed them. Identity comes from req.user, which came from a verified signature. Anything in the body, query, or params is user input.


Step 8: Logout and the kill switch

router.post('/logout', async (req, res) => {
  const presented = req.cookies?.[REFRESH_COOKIE];

  if (presented) {
    const stored = await tokenStore.findByHash(hashRefreshToken(presented));
    if (stored) {
      // Kill the whole family, not just this token — otherwise the
      // rest of the chain stays valid.
      await tokenStore.revokeFamily(stored.family_id);
    }
  }

  res.clearCookie(REFRESH_COOKIE, refreshCookieOptions());
  return res.status(204).end();
});

// "Log out everywhere" — use this on password change too.
router.post('/logout-all', requireAuth, async (req, res) => {
  await tokenStore.revokeAllForUser(req.user.id);
  res.clearCookie(REFRESH_COOKIE, refreshCookieOptions());
  return res.status(204).end();
});
Enter fullscreen mode Exit fullscreen mode

Call revokeAllForUser whenever a user changes their password, changes their email, or disables 2FA. This is the kill switch my friend didn't have.

One limitation is worth being honest about: access tokens already issued stay valid until they expire. Logout kills the refresh chain, so the session can't continue past the current access token, but there's a window of up to fifteen minutes.

If your app can't tolerate that window, meaning banking, healthcare, anywhere "log out" has to mean now, you need a denylist. On logout, write the access token's jti to Redis with a TTL matching its remaining lifetime and have requireAuth check it. That costs a Redis lookup on every request and gives back some of the statelessness you chose JWTs for. Most apps should shorten the access TTL to five minutes and accept the gap.


Step 9: The frontend half

The backend is only half of this, and getting the client wrong undoes the rest.

// src/lib/api.js
import axios from 'axios';

let accessToken = null;
let refreshPromise = null;

export const setAccessToken = (t) => { accessToken = t; };
export const getAccessToken = () => accessToken;

export const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  withCredentials: true,   // required, or the refresh cookie never goes out
});

api.interceptors.request.use((cfg) => {
  if (accessToken) cfg.headers.Authorization = `Bearer ${accessToken}`;
  return cfg;
});

async function refreshAccessToken() {
  const { data } = await axios.post(
    `${import.meta.env.VITE_API_URL}/auth/refresh`,
    {},
    { withCredentials: true }
  );
  setAccessToken(data.accessToken);
  return data.accessToken;
}

api.interceptors.response.use(
  (res) => res,
  async (error) => {
    const original = error.config;

    if (error.response?.status !== 401 || original._retried) {
      return Promise.reject(error);
    }

    // Only an expired token is worth refreshing. Anything else
    // means the session is genuinely dead.
    if (error.response.data?.code !== 'TOKEN_EXPIRED') {
      handleLoggedOut();
      return Promise.reject(error);
    }

    original._retried = true;

    // Single-flight: every concurrent 401 awaits the same refresh.
    refreshPromise ??= refreshAccessToken().finally(() => {
      refreshPromise = null;
    });

    try {
      const fresh = await refreshPromise;
      original.headers.Authorization = `Bearer ${fresh}`;
      return api(original);
    } catch (err) {
      handleLoggedOut();
      return Promise.reject(err);
    }
  }
);

function handleLoggedOut() {
  setAccessToken(null);
  window.location.href = '/login';
}
Enter fullscreen mode Exit fullscreen mode

Four things there are doing real work.

refreshPromise ??= is the single-flight guard, the client-side half of the race fix from Step 6. Ten requests fail at once, the first starts a refresh, the other nine await that same promise. One network call, one rotation, no false reuse detection.

original._retried prevents an infinite loop, which is what you get when a refresh succeeds but the retried request still fails.

Checking code === 'TOKEN_EXPIRED' stops you from trying to refresh a session that's genuinely over.

withCredentials: true appears on both the instance and the raw refresh call. Miss it and the cookie never goes out, silently, which makes it maddening to debug. Your API needs matching CORS:

app.use(cors({
  origin: process.env.FRONTEND_URL,   // never '*' with credentials
  credentials: true,
}));
Enter fullscreen mode Exit fullscreen mode

Every browser rejects origin: '*' alongside credentials: true. If your refresh works in Postman and dies in the browser, this is usually the reason.

"Won't users get logged out every time they reload?"

This is the standard objection to in-memory tokens and it has a clean answer: on app boot, call /auth/refresh once.

The access token is gone, since it lived in a JavaScript variable. The refresh cookie survived the reload, because that's what cookies do. So you exchange it for a fresh access token before rendering anything.

// src/App.jsx
import { useEffect, useState } from 'react';
import { api, setAccessToken } from './lib/api';

export default function App() {
  const [state, setState] = useState({ status: 'loading', user: null });

  useEffect(() => {
    let cancelled = false;

    (async () => {
      try {
        const { data } = await api.post('/auth/refresh');
        if (cancelled) return;
        setAccessToken(data.accessToken);
        setState({ status: 'authenticated', user: data.user });
      } catch {
        if (!cancelled) setState({ status: 'anonymous', user: null });
      }
    })();

    return () => { cancelled = true; };
  }, []);

  if (state.status === 'loading') return <FullPageSpinner />;
  return state.status === 'authenticated'
    ? <AuthenticatedApp user={state.user} />
    : <LoginPage />;
}
Enter fullscreen mode Exit fullscreen mode

One extra round trip on cold load, and in exchange the credential never touches localStorage. That's a very good trade.

If you're managing this with Zustand, as in my Axios + Zustand + Persist post, one warning: do not put the access token in the persisted slice. persist writes to localStorage, which reintroduces the exact problem this architecture exists to avoid. Persist the user profile for a nicer loading state if you want. Keep the token in a field that never gets written out.


Step 10: Wiring the server together

// src/server.js
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import authRoutes from './routes/auth.js';
import { config } from './config/env.js';

const app = express();

app.set('trust proxy', 1);        // behind Nginx — required for correct req.ip
app.use(helmet());
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }));
app.use(express.json({ limit: '10kb' }));
app.use(cookieParser());

app.use('/auth', authRoutes);

app.use((err, req, res, _next) => {
  console.error(err);
  res.status(500).json({ error: 'Internal server error' });
});

app.listen(config.port, () => console.log(`up on :${config.port}`));
Enter fullscreen mode Exit fullscreen mode

app.set('trust proxy', 1) matters if you deployed the way I described in the Nginx + PM2 post. Without it req.ip is 127.0.0.1 for every request, which means your rate limiter throttles all users as a single client and your security logs are worthless. Make sure Nginx forwards the real address:

proxy_set_header X-Real-IP        $remote_addr;
proxy_set_header X-Forwarded-For  $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
Enter fullscreen mode Exit fullscreen mode

The footgun list

Quick reference. Most of these I've either shipped myself or caught in review.

Mistake What it costs you
Access token in localStorage Any XSS is a permanent account takeover
No algorithms: ['HS256'] on verify alg: none and algorithm confusion attacks
Access token TTL measured in days Nothing to revoke, huge damage window
Same secret for access and refresh One leak compromises both layers
Secret is "secret" or "mysecretkey" Anyone can mint valid tokens for any user
Sensitive data in the JWT payload It's base64. Everyone can read it
No expiresIn Immortal tokens
Refresh tokens stored in plaintext DB leak = every live session handed over
Refresh token not rotated Theft is undetectable
No rate limit on /login Free credential stuffing
Trusting req.body.userId Any user reads any user's data
Generic 401 with no error code Infinite refresh loops or constant logouts
origin: '*' with credentials: true CORS silently blocks every cookie
Logging tokens Your logs become a credential store
Never cleaning refresh_tokens A table with ten million dead rows

So why not just use sessions?

I want to end on this, because it's the question you should have been asking the whole way down.

If you've been paying attention, something awkward has become obvious: we hit the database on every refresh. We store token state. We revoke it. That's a session table. We've built sessions with extra steps.

Fair criticism. Here's my honest position.

Plain sessions win when you have one backend serving one frontend, everything lives in one place, and you already run Redis or Postgres. express-session plus a store is fewer moving parts, revocation is instant and total, and there's no rotation logic to get subtly wrong. For most projects most of the time, that's the right answer. The reason people skip it is that it feels less modern, which is not a technical reason.

The split-token setup wins when several services need to verify identity without calling a central auth service, or you have a mobile app where cookie handling is awkward, or third-party clients consume your API, or your access checks are hot enough that a per-request database lookup shows up in your latency numbers.

The real advantage here isn't statelessness. It's the ratio. Your access token gets checked thousands of times with zero database work. Your refresh token gets checked once every fifteen minutes with full stateful control. Fast verification where verification is frequent, real revocation where revocation matters.

That's the actual argument for JWTs. Not "stateless is better." Nobody needs the auth on their CRUD app to be stateless.


Production checklist

  • [ ] Both secrets are 32+ random bytes, and they differ from each other
  • [ ] Secrets come from the environment, never from source
  • [ ] Access token TTL is 15 minutes or less
  • [ ] algorithms is pinned explicitly on every jwt.verify call
  • [ ] Refresh tokens are hashed in the database
  • [ ] Rotation is on, with reuse detection and a grace window
  • [ ] Cookie is httpOnly, secure, sameSite, path-scoped
  • [ ] /login, /register, and /refresh are rate limited
  • [ ] Password change calls revokeAllForUser
  • [ ] CORS origin is your real frontend URL, with credentials: true
  • [ ] trust proxy is set if you're behind Nginx
  • [ ] Reuse-detection events are logged and alert someone
  • [ ] A cron job clears expired tokens
  • [ ] No token, hash, or password appears in any log line
  • [ ] The frontend does single-flight refresh
  • [ ] The access token is not in localStorage, sessionStorage, or a persisted store

Auth is the one part of your app where the failure mode isn't a bug report. It's a message from someone who can't get a stranger out of their account.

The whole design reduces to a single idea: separate the credential you use constantly from the credential you can take back. The access token is fast, short-lived, and disposable, and if it leaks you lose fifteen minutes. The refresh token is slow, tracked, rotated, and revocable, and if it leaks, rotation catches the theft the next time either party uses it. Neither one gets you there alone. Together you get speed on the hot path and control on the cold one.

Build the revocation path before you need it. That's what my friend was missing, and it's what the tutorials leave out.


If this was useful, follow me. I write about backend, DevOps, and the things that break in production. Next up: automating VPS deploys with GitHub Actions.

Top comments (1)

Collapse
 
circuit profile image
Rahul S

The rotation-with-reuse-detection is the right shape, but I'd worry the 10s grace window is papering over a race that bites honest users harder than attackers. Picture a mobile client: it POSTs /refresh, you rotate and mark the old token revoked, then the response dies in a tunnel. The client never got the new token, retries with the old one — and now your reuse detector nukes the whole family and logs a real user out everywhere. Two tabs both firing on a 401 do the exact same thing. The grace window "fixes" this by tolerating a second presentation for a few seconds, but that same window is precisely what lets a fast thief slip through, so you can't widen it for honest retries without also widening it for theft — it's one knob doing two jobs. What worked better for me was making rotation idempotent instead of time-bounded: when you rotate old→new, persist that mapping, so a repeat of old returns the same new you already minted (no clock involved), and you only treat it as theft when a token whose successor was already consumed shows up again from further back in the chain. That separates "client retried" from "someone replayed" structurally rather than with a stopwatch.