Implementing JWT Refresh Token Rotation With Reuse Detection in Node.js

By James Nguyen Updated September 24, 2026
Implementing JWT Refresh Token Rotation With Reuse Detection in Node.js

Every JWT tutorial I read before building this stopped at "issue an access token and a refresh token," which is the easy 20% of the problem. The part that actually matters, what happens when a refresh token gets stolen, is the part almost none of them cover. I ended up building refresh token rotation with reuse detection for a production API, and I'm writing down the actual implementation, not the simplified version, because the gap between the two is where the security actually lives.

Why a Static Refresh Token Isn't Good Enough

The naive pattern issues one long-lived refresh token at login and reuses it for every subsequent access token refresh until it expires, thirty days later in a lot of implementations. If that token is stolen, from a compromised device, an XSS vulnerability, or a leaked log, the attacker has a valid session for the full thirty days, and there's no way to tell the legitimate user's traffic apart from the attacker's. Rotation fixes this by treating every refresh as one-time use: each time a refresh token is redeemed, it's immediately invalidated and a new one is issued in its place.

Why Rotation Alone Isn't Enough Either

Rotation by itself just moves the problem. If an attacker steals a refresh token and uses it before the legitimate user does, the attacker gets a new valid token and the legitimate user's session silently breaks. Reuse detection is what closes this gap: when an already-used, already-revoked refresh token gets presented again, that's a signal something is wrong, either a stolen token or a client bug, and the correct response is to revoke every token in that session's family, not just the one being reused.

The Data Model: Token Families

Instead of storing individual tokens, I track a family, a UUID generated at login that stays constant across every rotation in that login session. Each row in the refresh_tokens table stores a hash of the token (never the raw value, the same principle as password storage), the family id, whether it's been used, and its expiry. A reused, already-marked-used token pointing at a family is the trigger to revoke the whole family.

CREATE TABLE refresh_tokens (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id),
  family_id UUID NOT NULL,
  token_hash TEXT NOT NULL,
  used BOOLEAN NOT NULL DEFAULT FALSE,
  expires_at TIMESTAMPTZ NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_refresh_family ON refresh_tokens(family_id);

Issuing the Initial Token Pair at Login

At login, I generate a new family_id, sign a short-lived access token (15 minutes, carrying only the user id and a session version claim I'll explain below), and sign a refresh token containing the family_id and a random jti, then store its hash. The access token goes to the client in the response body for in-memory storage; the refresh token goes in an HttpOnly, Secure, SameSite=Strict cookie, never in a JSON body a script could read.

const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const { v4: uuidv4 } = require('uuid');

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

async function issueTokenPair(userId, familyId = uuidv4()) {
  const accessToken = jwt.sign(
    { sub: userId, type: 'access' },
    process.env.ACCESS_TOKEN_SECRET,
    { expiresIn: '15m' }
  );

  const jti = uuidv4();
  const refreshToken = jwt.sign(
    { sub: userId, familyId, jti, type: 'refresh' },
    process.env.REFRESH_TOKEN_SECRET,
    { expiresIn: '30d' }
  );

  await db.refresh_tokens.insert({
    user_id: userId,
    family_id: familyId,
    token_hash: hashToken(refreshToken),
    expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
  });

  return { accessToken, refreshToken, familyId };
}

The Rotation Endpoint, Where Reuse Detection Actually Happens

This is the endpoint that matters most. It verifies the incoming refresh token's signature, looks up its hash in the database, and branches on the used flag: if the token isn't found at all, or is found but already marked used, that's treated as suspected theft and the entire family gets revoked. Only a valid, not-yet-used token gets marked used and produces a new pair.

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

  let payload;
  try {
    payload = jwt.verify(token, process.env.REFRESH_TOKEN_SECRET);
  } catch {
    return res.status(401).json({ error: 'invalid token' });
  }

  const record = await db.refresh_tokens.findOne({
    token_hash: hashToken(token),
  });

  if (!record || record.used) {
    // Token not found, or already used once before — possible theft.
    // Revoke the entire family so both the attacker and the legitimate
    // user are forced to re-authenticate.
    await db.refresh_tokens.updateMany(
      { family_id: payload.familyId },
      { used: true }
    );
    return res.status(401).json({ error: 'refresh token reuse detected' });
  }

  await db.refresh_tokens.update(record.id, { used: true });
  const newPair = await issueTokenPair(payload.sub, payload.familyId);

  res.cookie('refreshToken', newPair.refreshToken, {
    httpOnly: true, secure: true, sameSite: 'strict',
    maxAge: 30 * 24 * 60 * 60 * 1000,
  });
  res.json({ accessToken: newPair.accessToken });
});

Handling the Race Condition Two Tabs Create

The first real bug I hit wasn't a security flaw, it was two browser tabs both triggering a refresh at nearly the same moment, both reading the same not-yet-used token, and the second one hitting the "already used" branch and wrongly nuking a legitimate session. Wrapping the read-and-mark-used step in a single atomic UPDATE ... WHERE used = false RETURNING *, rather than a separate SELECT followed by an UPDATE, closed this race, only one of the two concurrent requests can win the update, and the loser gets the reuse-detected path cleanly instead of a torn read.

Session Versioning for Immediate Revocation

Rotation handles refresh tokens, but a stolen access token is still valid for up to fifteen minutes with no way to kill it early, since verifying a JWT doesn't hit the database by design. Adding a sv (session version) claim to the access token, checked against a sessionVersion column on the user record on every request, gives a cheap way to force-invalidate every outstanding access token immediately: increment the column on password reset or a manual "log out everywhere," and every existing access token fails that comparison on its next use, no revocation list required.

Final Verdict

The rotation-plus-reuse-detection pattern is more code than a basic access/refresh pair, a token_family concept, an atomic claim-on-refresh update, and a session version claim, but each piece is solving a specific, real attack, not adding complexity for its own sake. If your API stores refresh tokens as plain strings and reuses the same one for thirty days, this is worth the afternoon it takes to retrofit, before an incident makes the decision for you instead.

Daniel Justin

About the Author

James Nguyen is a full-stack programmer with more than ten years of experience engineering software systems. Specializing in the Node.js and Python ecosystems, he focuses on backend architecture, API design, and clean data integration. Follow me on YouTube and Instagram.

More Articles