It was 2 a.m. when the security team got the alert: a single refresh token had just been used twice on two different continents. The user’s account was locked, the fraud investigators were scrambling, and the engineers realized the static refresh token they’d been issuing for months had become an open back‑door. If the token had been rotated, the second request would have failed instantly, buying precious minutes to contain the breach.

⚡ TL;DR — Key takeaways
  • Rotate refresh tokens on every use to shrink the attack window.
  • Group tokens into a “family” and revoke the whole family on reuse detection.
  • Store only hashed tokens and track status in a fast data store.
  • Use idempotent, transactional logic to survive concurrent refresh calls.
  • Balance strict rotation with multi‑device UX through scoped families.

Before you start: Node 20 (or Python 3.12), a PostgreSQL 15 instance, Redis 7, and a solid grasp of OAuth 2.0/OAuth2.1 flows.

A Step-by-Step Guide to Implementing Refresh Token Rotation and Reuse Detection

Refresh token rotation invalidates a used refresh token and issues a new one, limiting the window for misuse if leaked. Reuse detection marks all tokens in a “family” as compromised if an already‑used token is presented again, a sign of theft. Implementation requires a secure backend service, a database to track token families and statuses, and logic to handle the token exchange and security responses.

The Core Problem: Why Static Refresh Tokens Are a Security Risk

Static refresh tokens live for weeks or months. If an attacker steals one, they can keep minting fresh access tokens forever. Because the token never changes, the breach can stay hidden until the user notices odd activity. Rotating the token after each use guarantees that a leaked token becomes useless after the first legitimate refresh.

“Reuse detection is not just revoking a token; it’s a signal of a broader account compromise that should trigger automated response workflows.” – Security Engineering Lead, Major Cloud Provider

System Architecture Overview: The Token Management Layer

Below is a high‑level flow of the token endpoint. The client presents a refresh token, the server validates it, issues a new access token and a fresh refresh token, then marks the old one as consumed.

flowchart TD
    A[Client → /refresh] --> B{Validate token}
    B -->|Valid| C[Generate new access JWT]
    B -->|Valid| D[Generate new refresh token]
    B -->|Invalid| E[Return 401]
    C --> F[Return access JWT]
    D --> G[Store hashed new token]
    G --> F
    E --> H[Log reuse attempt]
    H --> I[Revoke family]

Step 1: Understanding the Token Lifecycle

Defining Access Tokens, Refresh Tokens, and Their Scopes

Access tokens are short‑lived (5‑15 minutes) JWTs that carry user claims. They are signed with a private RSA key (e.g., RS256) and verified by resource servers without contacting the auth server. Refresh tokens are opaque, long‑lived credentials stored server‑side; they never travel beyond the /refresh endpoint.

The Critical Role of the Authorization Server

The auth server is the only component that can issue, rotate, or revoke tokens. It must enforce the Principle of Least Privilege by embedding only required scopes in the access token and tying refresh‑token families to a single client identifier.

The Client’s Responsibilities for Secure Storage

Native apps should keep refresh tokens in OS‑protected keychains. SPAs must avoid storing them altogether; use the Authorization Code Flow with PKCE to keep the token exchange server‑side. For server‑to‑server APIs, store the token in an encrypted environment variable or secret manager.

Step 2: Designing the Database Schema

Essential Columns: Hashed Token, User ID, Family, Status

ColumnTypeDescription
token_hashBYTEA (SHA‑256)Hash of the raw refresh token (never store plaintext)
user_idUUIDOwner of the token
family_idUUIDIdentifier grouping rotated tokens
issued_atTIMESTAMPTZWhen this token was created
expires_atTIMESTAMPTZAbsolute expiry
statusENUMactive, consumed, revoked
device_fingerprintTEXTOptional, helps differentiate devices

Should You Use SQL or NoSQL? A Performance Trade‑off

SQL databases give you ACID guarantees, making it easy to atomically “consume‑and‑replace” a token. NoSQL stores like Redis provide sub‑millisecond lookups for high‑throughput scenarios but require a separate persistence strategy (e.g., Redis‑AOF + periodic dump). A hybrid approach—primary storage in PostgreSQL, hot lookup cache in Redis—covers both bases.

Example Schema for PostgreSQL and Redis Implementations

PostgreSQL (v15) DDL

CREATE TABLE refresh_token_family (
    token_hash BYTEA PRIMARY KEY,
    user_id UUID NOT NULL,
    family_id UUID NOT NULL,
    issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at TIMESTAMPTZ NOT NULL,
    status TEXT NOT NULL CHECK (status IN ('active','consumed','revoked')),
    device_fingerprint TEXT
);

CREATE INDEX idx_user_family ON refresh_token_family (user_id, family_id);
CREATE INDEX idx_status_expiry ON refresh_token_family (status, expires_at);

Redis (v7) hash structure

RT:<token_hash> => {
    user_id: "<uuid>",
    family_id: "<uuid>",
    status: "active",
    expires_at: "<epoch>"
}

A background cleanup job (e.g., using BullMQ in Node or Celery in Python) removes records past expires_at. See our tutorial on Implementing Scheduled Jobs with your preferred framework (e.g., Celery, BullMQ, Sidekiq) for details.

Step 3: Implementing Core Rotation Logic

Algorithmic Walkthrough: The /refresh Endpoint Flow

  1. Receive raw refresh token from the client.
  2. Hash the token using SHA‑256 (constant‑time comparison).
  3. Lookup the hash in Redis; fallback to PostgreSQL if cache miss.
  4. Validate status = active and expires_at > now.
  5. Begin a DB transaction.
  6. Mark current token as consumed.
  7. Generate a new opaque token, hash it, insert a new row with the same family_id.
  8. Commit transaction.
  9. Return new access JWT + fresh refresh token.

All steps must be idempotent: if the client retries because of a network glitch, the server should detect the already‑consumed token and either re‑issue the same fresh token (if stored) or gracefully fail with a clear error.

Code Example: The Token Family “Bump” and Reissue Process (Node 20)

// tokenRefresh.js — Node 20, Express 4.18
import crypto from 'crypto';
import { db, redis } from './dbClients.js'; // pg & ioredis instances
import { signJwt } from './jwtUtils.js';   // RS256, kid from JWKS
import { v4 as uuidv4 } from 'uuid';

const REFRESH_TTL = 24 * 60 * 60; // 24h

export async function handleRefresh(req, res) {
  const rawToken = req.body.refresh_token;
  if (!rawToken) return res.status(400).json({ error: 'Missing token' });

  const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');

  // Fast path: Redis
  let meta = await redis.hgetall(`RT:${tokenHash}`);
  if (!meta?.status) {
    // Cache miss → Postgres
    const row = await db.query(
      `SELECT * FROM refresh_token_family WHERE token_hash = $1`,
      [tokenHash]
    );
    if (!row.rowCount) return reuseOrInvalid(res);
    meta = row.rows[0];
    // Prime cache
    await redis.hmset(`RT:${tokenHash}`, {
      user_id: meta.user_id,
      family_id: meta.family_id,
      status: meta.status,
      expires_at: meta.expires_at.getTime(),
    });
  }

  // Validate
  if (meta.status !== 'active' || meta.expires_at < Date.now()) {
    return reuseOrInvalid(res);
  }

  const client = await db.connect();
  try {
    await client.query('BEGIN');

    // Mark consumed
    await client.query(
      `UPDATE refresh_token_family SET status = $1 WHERE token_hash = $2`,
      ['consumed', tokenHash]
    );

    // Issue new token
    const newRaw = crypto.randomBytes(48).toString('base64url');
    const newHash = crypto.createHash('sha256').update(newRaw).digest('hex');
    const expiresAt = new Date(Date.now() + REFRESH_TTL * 1000);
    await client.query(
      `INSERT INTO refresh_token_family
       (token_hash, user_id, family_id, expires_at, status)
       VALUES ($1, $2, $3, $4, $5)`,
      [newHash, meta.user_id, meta.family_id, expiresAt, 'active']
    );

    await client.query('COMMIT');

    // Cache the new token
    await redis.hmset(`RT:${newHash}`, {
      user_id: meta.user_id,
      family_id: meta.family_id,
      status: 'active',
      expires_at: expiresAt.getTime(),
    });

    // Issue short‑lived access JWT
    const accessToken = signJwt({ sub: meta.user_id }, '5m');

    res.json({
      access_token: accessToken,
      refresh_token: newRaw,
      expires_in: 300,
    });
  } catch (err) {
    await client.query('ROLLBACK');
    console.error('Refresh error:', err);
    res.status(500).json({ error: 'Internal server error' });
  } finally {
    client.release();
  }
}

function reuseOrInvalid(res) {
  // Mark family as compromised (see Step 4) and return generic error
  // Avoid leaking which condition triggered it.
  res.status(401).json({ error: 'Invalid or reused refresh token' });
}

The snippet showcases:

  • Constant‑time hashing.
  • Transactional state change.
  • Cache priming.
  • Graceful error handling.

Handling Edge Cases: Concurrent Requests and Network Failures

If two devices simultaneously present the same refresh token, both will read active before either writes consumed. To prevent double‑issuance, lock the row (SELECT … FOR UPDATE) inside the transaction. In PostgreSQL:

SELECT * FROM refresh_token_family
WHERE token_hash = $1
FOR UPDATE SKIP LOCKED;

If a request times out after the client received a new token but before the server committed, the client may retry with the old token. Treat that as a reuse attempt; invoke the detection routine (Step 4) to invalidate the whole family.

Step 4: Building Reuse Detection

The Security Trigger: What Constitutes “Reuse”?

A token is considered reused when its status is not active (i.e., consumed or revoked) and yet it is presented to /refresh. This pattern almost always indicates that an attacker intercepted the token after the legitimate client already rotated it.

Implementation: Marking Compromised Families and Revoking All Sessions

When reuse is detected:

  1. Look up the family_id of the offending token.
  2. Update all rows with that family_id to revoked.
  3. Emit an event to a security‑orchestration service (e.g., AWS EventBridge) to trigger MFA challenges, email alerts, and possible account lock.
async function flagFamilyCompromise(familyId) {
  await db.query(
    `UPDATE refresh_token_family
     SET status = $1
     WHERE family_id = $2`,
    ['revoked', familyId]
  );

  // Invalidate cache entries
  const keys = await redis.keys(`RT:*`);
  for (const k of keys) {
    const fId = await redis.hget(k, 'family_id');
    if (fId === familyId) await redis.hset(k, 'status', 'revoked');
  }

  // Async notification
  process.nextTick(() => {
    // pseudo‑code: push to notification service
    notifySecurityTeam(familyId);
  });
}

Code Pattern: Asynchronous Alerting and User Notification

A simple webhook to Slack (or an internal ticketing system) can be fired asynchronously to avoid slowing the refresh response. Using node-fetch v3:

async function notifySecurityTeam(familyId) {
  const payload = {
    text: `🚨 Refresh token family ${familyId} compromised`,
    channel: '#security-alerts',
  };
  await fetch('https://hooks.slack.com/services/XXXX/XXXX/XXXX', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
}

The user receives an email generated by a separate worker that forces a password reset and offers a one‑time passcode.

Step 5: System Design Considerations

Performance: Caching Strategies and Database Indexing

  • Write‑through cache: every successful rotation writes to Redis after committing to Postgres.
  • TTL: set Redis key expiry to match expires_at to avoid stale entries.
  • Indexes: composite index on (user_id, family_id) accelerates revocation scans; index on (status, expires_at) speeds cleanup jobs.

Scalability: Partitioning Schemes for High‑Volume Token Tables

When token records exceed tens of millions, horizontal partitioning (sharding) by user_id hash reduces hotspot contention. PostgreSQL’s native partitioning works well:

CREATE TABLE refresh_token_family_y2024m01 PARTITION OF refresh_token_family
FOR VALUES WITH (MODULUS 32, REMAINDER 0);
-- repeat for remainder 1..31

For a deep dive on partitioning, see our guide on How to Implement Sharding in MongoDB: A Comprehensive Guide with Examples.

Trade‑offs: Strictness vs. User Experience (Single‑ vs. Multi‑Device Use)

A naïve design binds every device to a single family, causing a legitimate second device to be revoked when the first rotates. Mitigation strategies:

  • Device‑scoped families: include a device_fingerprint column and create a separate family per device.
  • Grace window: allow a short overlap (e.g., 30 seconds) where both the old and new token are accepted.
  • Refresh‑token “delegation”: issue short‑lived device‑specific refresh tokens that inherit a master family.

Each approach adds storage overhead but preserves a smoother UX for users with multiple browsers or mobile apps.

Common Implementation Pitfalls and Optimizations

Pitfall 1: Incorrectly Hashing Tokens Before Storage

Storing the raw token exposes it to insider threats and accidental logs. Some developers hash with MD5 or without a salt, making pre‑image attacks trivial. Use SHA‑256 (or stronger) in a constant‑time comparison routine, as shown earlier.

Pitfall 2: Failing to Handle Token Family “Promotion” Gracefully

When a device logs out, developers often delete the entire family, inadvertently cutting off other active devices. Instead, only mark the specific token as revoked and leave the family alive for remaining devices.

Optimization: Signed Token Metadata to Reduce Database Lookups

Embedding a signed, encrypted blob of family_id and status into the refresh token itself allows the server to verify integrity without a DB hit for every request. The server still needs to check the token hasn’t been revoked, but it can do so lazily (e.g., once per hour) using a bloom filter. This hybrid approach keeps the common path fast while preserving strong security guarantees.

“A fintech startup implementing strict rotation and reuse detection reduced account takeover incidents by 94% over 12 months.” – Case Study, internal report.

Common Errors & Fixes

Error you seeWhy it happensHow to fix
401 Unauthorized even after a fresh loginThe token cache still holds a stale revoked statusFlush Redis keys for the user or set a short TTL for cache entries
Duplicate refresh token issued (two different access tokens)Lost DB row lock due to missing FOR UPDATE clauseAdd row‑level locking in the transaction, or use PostgreSQL advisory locks
Memory pressure in RedisNo expiration set on token keysUse EXPIRE with the same TTL as expires_at when inserting
Cleanup job saturates CPUFull table scan on every runIndex on (status, expires_at) and paginate deletions in batches
User complains of logout on second deviceAll devices share a single familySplit families per device fingerprint or introduce a grace window

Frequently asked questions

How long should my refresh tokens live?

There’s no universal rule. Short-lived (e.g., 24 hours) with automatic rotation improves security but increases server load. Long-lived (e.g., 90 days) improves UX but widens the attack window. Choose a duration that matches your risk profile and compliance requirements.

What’s the difference between token revocation and reuse detection?

Revocation is a manual, administrative action to invalidate a token. Reuse detection is an automated safeguard that invalidates an entire token family when a single token is presented more than once, indicating a probable leak.

Can I implement this without a database lookup on every API call?

Yes, for access tokens. Use short‑lived JWTs for access and verify them locally. The stateful lookup is confined to the `/refresh` endpoint, where you check the refresh token’s status, family, and apply rotation logic.

My take: Implementing rotation and reuse detection feels like adding a second lock to a door. The first lock (access token) blocks casual thieves; the second (refresh rotation) stops a burglar who somehow snuck the key. The extra complexity pays off in environments where credential theft is a daily headline.

Next steps

  1. Scaffold the token service with the schema above.
  2. Write integration tests that simulate concurrent refresh attempts.
  3. Deploy with a canary release, monitor the reuse metric, and fine‑tune the grace window.

By following the steps in this guide, you’ll move from a fragile static‑token model to a hardened, production‑ready architecture that thwarts token‑theft attacks while keeping the user experience fluid.

If you found this walkthrough useful, drop a comment with your own experiences, share the article with teammates, or suggest improvements. Happy coding!

Written by

’m Nilesh, a Software Development Engineer with 2+ years of experience, specializing in Go, JavaScript, Python, Docker, Kubernetes, Git, Jenkins, microservices, and system design (LLD/HLD), backed by a strong foundation in data structures and algorithms. Alongside my engineering journey, I bring 4+ years of hands-on experience in SEO, where I’ve worked extensively on content strategy, keyword research, technical SEO, and organic growth, helping products and businesses scale efficiently by aligning solid technology with search-driven performance.