DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Implementing Secure API Gateways for Microservices Architecture
  • Building a Production-Ready MCP Server in Python
  • JWT Policy Enforcement, Rate Limiting, IP White Listing: Using Mulesoft, API Security, Cloudhub 2.0
  • Secure Your Frontend: Practical Tips for Developers

Trending

  • AGENTS.md Makes Your Java Codebase AI-Agent Ready
  • Going Stateless: Scaling MCP Servers to Cloud-Native Java and HTTP
  • GraphRAG in Practice Using Spring AI, Neo4j, and Goodreads Data
  • Scaling Teams, Scaling Systems: Unlocking Developer Productivity With Platform Engineering
  1. DZone
  2. Software Design and Architecture
  3. Security
  4. Refresh Token Rotation in Node.js: Stopping Token Theft Without Logging Users Out

Refresh Token Rotation in Node.js: Stopping Token Theft Without Logging Users Out

Implementing refresh token rotation with reuse detection, a pattern that limits the damage of a stolen token while keeping legitimate users logged in.

By 
Bilal Azam user avatar
Bilal Azam
·
Jul. 22, 26 · Analysis
Likes (0)
Comment
Save
Tweet
Share
114 Views

Join the DZone community and get the full member experience.

Join For Free

JWT-based authentication is simple to start with and surprisingly hard to get right. The naive setup of a long-lived access token stored in the browser is a security liability. The textbook fixes short-lived access tokens plus a refresh token — introduce their own problem: 

What Happens When a Refresh Token Is Stolen?

This article walks through implementing refresh token rotation with reuse detection, a pattern that limits the damage of a stolen token while keeping legitimate users logged in. All examples are in Node.js with Express.

Why a Single Long-Lived Token Is Dangerous

If you issue one access token that lives for days, you have no way to revoke it before it expires. If it leaks through an XSS bug, a logging mistake, or a compromised device, an attacker has full access until expiry, and you cannot do anything about it.

Short-lived access tokens (say, 15 minutes) limit this window. But you cannot ask users to log in every 15 minutes, so you pair the access tokens with a longer-lived refresh token whose only job is to mint new access tokens.

The New Problem: Stolen Refresh Tokens

A refresh token is now the crown jewel. If an attacker steals it, they can mint access tokens indefinitely. Simply making it long-lived recreates the original problem at a higher level.

Rotation addresses this: every time a refresh token is used, it is invalidated, and a brand-new refresh token is issued. A stolen token is only useful until the legitimate user next refreshes, at which point the stolen token becomes invalid.

But rotation alone is not enough. Consider the race:

  1. Attacker steals refresh token R1.
  2. Legitimate user refreshes with R1, gets R2. R1 is now invalid.
  3. Attacker tries R1. It is rejected, but the system does not yet know a theft occurred.

The missing piece is reuse detection: if an already-rotated token is presented again, that is a strong signal of theft, and the entire token family should be revoked.

Implementing Token Families

The key concept is the token family. When a user logs in, you create a family with a shared family_id. Every rotation issues a new token in the same family. If any consumed token in the family is ever presented again, you revoke the whole family, forcing the attacker (and the victim) to re-authenticate.

Here is the schema:

SQL
 
CREATE TABLE refresh_tokens (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  family_id   UUID NOT NULL,
  user_id     INTEGER NOT NULL,
  token_hash  VARCHAR(255) NOT NULL,
  consumed    BOOLEAN NOT NULL DEFAULT false,
  expires_at  TIMESTAMPTZ NOT NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_family ON refresh_tokens (family_id);


Note that we store a hash of the token, never the token itself. If your database leaks, the stored hashes are useless to an attacker, the same reasoning behind hashing passwords.

Issuing Tokens at Login

TypeScript
 
const crypto = require('crypto');
const jwt = require('jsonwebtoken');

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

async function issueTokens(userId, familyId = crypto.randomUUID()) {
  const accessToken = jwt.sign(
    { sub: userId },
    process.env.ACCESS_SECRET,
    { expiresIn: '15m' }
  );

  const refreshToken = crypto.randomBytes(40).toString('hex');
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days

  await pool.query(
    `INSERT INTO refresh_tokens (family_id, user_id, token_hash, expires_at)
     VALUES ($1, $2, $3, $4)`,
    [familyId, userId, hashToken(refreshToken), expiresAt]
  );

  return { accessToken, refreshToken, familyId };
}


The Rotation Endpoint With Reuse Detection

This is where the security logic lives:

TypeScript
 
async function rotateRefreshToken(presentedToken) {
  const hash = hashToken(presentedToken);

  const result = await pool.query(
    `SELECT * FROM refresh_tokens WHERE token_hash = $1`,
    [hash]
  );

  if (result.rowCount === 0) {
    throw new Error('INVALID_TOKEN');
  }

  const token = result.rows[0];

  // REUSE DETECTION: a consumed token is being presented again.
  // This means the token was either stolen or replayed. Burn the family.
  if (token.consumed) {
    await pool.query(
      `DELETE FROM refresh_tokens WHERE family_id = $1`,
      [token.family_id]
    );
    throw new Error('TOKEN_REUSE_DETECTED');
  }

  if (new Date(token.expires_at) < new Date()) {
    throw new Error('EXPIRED_TOKEN');
  }

  // Mark this token consumed, then issue a fresh one in the same family.
  await pool.query(
    `UPDATE refresh_tokens SET consumed = true WHERE id = $1`,
    [token.id]
  );

  return issueTokens(token.user_id, token.family_id);
}


The crucial branch is the token consumed check. Under normal operation, a token is used exactly once and then never seen again. If a consumed token reappears, the only explanations are theft or a replay attack, so the system revokes every token in the family. The attacker is locked out, and the legitimate user is forced to log in again, which is the correct, safe outcome.

A Common Mistake: Forgetting the Grace Window

There is a subtle real-world wrinkle. Mobile clients on flaky networks sometimes fire two refresh requests for the same token because the first response was lost in transit. With strict reuse detection, the second request looks like an attack and nukes the family, logging out a user who did nothing wrong.

The pragmatic fix is a short grace window: if a consumed token is reused within a few seconds of being consumed, return the already-issued replacement token instead of revoking the family. This tolerates network retries without weakening protection against real theft, which happens on a much longer timescale.

TypeScript
 
const GRACE_MS = 10_000;
if (token.consumed) {
  const age = Date.now() - new Date(token.consumed_at).getTime();
  if (age < GRACE_MS) {
    // Likely a network retry — return the existing replacement.
    return getReplacementToken(token.family_id);
  }
  // Otherwise, treat as theft.
  await revokeFamily(token.family_id);
  throw new Error('TOKEN_REUSE_DETECTED');
}


(This requires adding a consumed_at timestamp and a pointer to the replacement token, omitted here for brevity.)

Takeaways

  • Keep access tokens short-lived (15 minutes is reasonable) and never rely on them being revocable.
  • Rotate refresh tokens on every use so a stolen token has a short useful life.
  • Detect reuse of consumed tokens and revoke the entire token family — this is what actually catches theft.
  • Store only hashes of refresh tokens, never the raw values.
  • Add a small grace window so flaky-network retries do not get misread as attacks.

Rotation with reuse detection is more code than a plain JWT setup, but it turns authentication from "hope nothing leaks" into a system that actively detects and contains compromise.

Node.js JWT (JSON Web Token) security

Opinions expressed by DZone contributors are their own.

Related

  • Implementing Secure API Gateways for Microservices Architecture
  • Building a Production-Ready MCP Server in Python
  • JWT Policy Enforcement, Rate Limiting, IP White Listing: Using Mulesoft, API Security, Cloudhub 2.0
  • Secure Your Frontend: Practical Tips for Developers

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook