← Back to Blog

JWT Refresh Token Rotation: Safe Pattern for Real Systems

Developer SecurityMar 28, 2026·9 min read
JWT refresh token rotation diagram

Most JWT tutorials stop at "use a refresh token for long-lived sessions." They rarely explain what happens when that refresh token is stolen. A static refresh token — one that never changes — gives an attacker the same persistent access as a stolen session cookie. Worse, there's no way to detect the theft.

Refresh token rotation solves this by issuing a new refresh token with every use. If an old token is replayed, the entire session is revoked. This article walks through the implementation pattern used by Auth0, Okta, and other serious identity providers.

Why Static Refresh Tokens Fail

A static refresh token has a fixed value for the entire session lifetime (often 30+ days). Problems:

The fix is to treat every refresh token as single-use.

The Rotation Model

Step 1: Login — Create a Token Family

When the user authenticates, generate a family_id (UUID) that groups all tokens in this session:

{
  family_id: "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  user_id: "user_123",
  refresh_token_hash: sha256(refresh_token),
  created_at: "2026-03-28T10:00:00Z",
  expires_at: "2026-04-27T10:00:00Z",
  used: false
}

Store this record server-side. Never store refresh tokens in localStorage — use httpOnly cookies or secure native storage.

Step 2: Token Refresh — Rotate

When the client presents the refresh token:

  1. Look up the token hash in the database
  2. Verify it belongs to an active family and is not marked as used
  3. Mark it as used: true
  4. Issue a new refresh token (new random value, same family_id)
  5. Issue a new access token
  6. Store the new refresh token hash

Step 3: Replay Detection

If a refresh token marked as used: true is presented again, this means either:

The safe response: revoke all tokens in the family. This forces the user to log in again. It's better to inconvenience a legitimate user once than to let an attacker maintain access.

// Replay detected — nuclear option
await db.query(
  'DELETE FROM refresh_tokens WHERE family_id = $1',
  [familyId]
);

Database Schema

A minimal refresh token table:

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

CREATE INDEX idx_refresh_family ON refresh_tokens(family_id);
CREATE INDEX idx_refresh_user ON refresh_tokens(user_id);

The replaced_by column creates a chain you can follow for audit purposes.

Handling Concurrent Requests

In real applications, multiple API calls might trigger a refresh simultaneously. If Call A rotates the token while Call B still holds the old one, Call B triggers replay detection — logging out the user unexpectedly.

Grace Period Pattern

Allow the previous refresh token to remain valid for a short window (5–10 seconds) after rotation:

const GRACE_PERIOD_MS = 10_000;

if (token.used) {
  const elapsed = Date.now() - token.rotated_at;
  if (elapsed < GRACE_PERIOD_MS) {
    // Within grace period — return the already-issued new tokens
    return getLatestTokensForFamily(token.family_id);
  }
  // Outside grace period — replay attack
  await revokeFamily(token.family_id);
  throw new UnauthorizedError('token_replayed');
}

Monitoring and Alerts

Track these signals in production:

Refresh Token vs Access Token: Quick Reference

PropertyAccess TokenRefresh Token
Lifetime5–15 minutes7–30 days
FormatJWT (self-contained)Opaque string (recommended)
StorageMemory (JS variable)httpOnly cookie
Sent toAPI serversAuth server only
RotationNot neededEvery use

For time-claim validation bugs that often interact with refresh flows, see JWT exp/iat/nbf common bugs.

FAQ

How should I handle concurrent refresh requests?

Use a short grace period (5–10 seconds) where the previous refresh token is still accepted. This handles race conditions when multiple API calls trigger a refresh simultaneously. After the grace period, only the newest token is valid.

Should refresh tokens be JWT or opaque strings?

Opaque strings are generally safer for refresh tokens. JWTs carry payload data that increases attack surface, and refresh tokens don't need to be self-contained since the server always looks them up in the database anyway.

How long should refresh sessions live?

7–30 days is typical. High-security applications (banking, healthcare) should use 1–7 days. Consumer apps can go up to 90 days with rotation. Always pair long sessions with device fingerprinting and anomaly detection.

Related Tools & Articles