# Stop Logging Out Innocent Users: Building Production Refresh Token Rotation with Token Family Revocation in Redis

Every engineering team that implements JWT authentication eventually runs into the dilemma of session lifetime.

If you issue long-lived access tokens (e.g., 30 days), you lose the ability to quickly revoke compromised sessions without querying a central database on every microsecond request. If you issue short-lived access tokens (e.g., 15 minutes) paired with a long-lived refresh token, you face a much harder question:

**What happens when an attacker steals the refresh token?**

The standard answer recommended by OAuth 2.0 Security Best Current Practice ([RFC 6749 BCP](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics)) is **Refresh Token Rotation (RTR)**: every time a refresh token is used to issue a new access token, the old refresh token is invalidated and a brand new refresh token is returned. If an old, already-consumed refresh token is presented again, the authentication server assumes a breach has occurred and immediately revokes all tokens descended from that login.

This concept is elegant on paper. In production, however, **naive implementations create an operational disaster**:

1.  **The Concurrency Trap:** A user opens three browser tabs simultaneously on startup. All three tabs notice their 15-minute access token has expired and fire `/auth/refresh` at the same instant. Tab 1 arrives 2 milliseconds ahead, consumes the refresh token, and receives a replacement. Tabs 2 and 3 arrive with the old token. The backend flags this as a replay attack and violently destroys the user's active session.
    
2.  **The Flaky Network Trap:** A mobile client on a train sends a refresh request. The server consumes the token and returns the replacement, but the cell tower handoff drops the HTTP response before reaching the device. The client retries with the original token, triggers the theft-detection alarm, and locks the user out.
    
3.  **The Server-Side Rendering (SSR) Trap:** Modern frontend architectures (such as Next.js React Server Components) execute server-side data fetching where headers and cookies cannot always be cleanly mutated via `Set-Cookie` in the middle of a streaming render.
    

This article walks through the exact mechanics of building a production-grade **Refresh Token Rotation and Theft Detection Engine** using **NestJS**, **Redis Lua scripts**, **token families**, and a **micro-grace window** that eliminates false-positive logouts while guaranteeing instant cutoff during an actual breach.

* * *

## 1\. The Anatomy of Token Theft

To understand why token families are necessary, let's trace the failure mode of naive refresh tokens.

### The Static Refresh Token Vulnerability

In simple authentication systems, a user logs in and receives:

-   An **Access Token** (JWT, 15m expiration) stored in memory or short-lived cookies.
    
-   A **Refresh Token** (opaque string or JWT, 30d expiration) stored in an `httpOnly` cookie or persistent local storage.
    

![Mermaid Diagram](https://mermaid.ink/img/pako:eNqVkk1v2zAMhv8Kq1MLKMk69GQMAZwNxQZs3ZCPnnKhLTomIkueROejRf77oNhpsAE97CRQfF8-rwS-qtIbUpmK9LsjV9IXxk3AZu0AALAT77qmoDDUpfgAz1wKN4ARvtOGhRsUglX8W5SLYLmlkGTLOhAK5KnRa1oMwiW36ATyTuqkOp8LCjsKcPuEvCOYL-d3a9dbnrwQ-NTs-fpCyK4sOlRsJaBQhDlVgWINS78lB7f9kd_1w_oRo-k0QTP49XOxhEkYHHuWiy3v5Uk1Gk2nvS2DOUkXXARH-0E4eyMAux1aNihkBtq_D7gm_wi170IEi0JhPB4PuKH_3_mugx8-3MPKYSe1D_xC5hoPbSA0R6BDy4HMpIvv5nz_ozlCYX25JaNhtlpeVmKP6U-S1XnhisnoT0WYTNEZ4Opqr9HA8BoyUHGIoi8zOEJLoUFHTuwRegr4Tm6UVk3qsFHZq5KamrS4hirsrCjd3zxjYCwsxaSpvJNHbNgeVaZG2LaWRvEYhRoNM8tu-wPLxbl-9E40rNWCNp5g9W2tNMx94cVr-Ep2R8IlasgDo9UQ0cVRpMCV0mfIgl9SlvuH9qBOJ62KzWdvfVCZutnXLKROfwCXSCeT?type=png)

If the attacker beats the victim to the refresh endpoint:

1.  Attacker presents Token A and receives Token B.
    
2.  When the legitimate user's client wakes up, it presents Token A.
    
3.  The server rejects Token A as invalid.
    
4.  The legitimate user is redirected to the login screen, assuming their session timed out.
    
5.  **The attacker retains complete access to the account using Token B.**
    

* * *

## 2\. The Solution: Token Families (`sid`)

To detect this compromise, we link every generated token into a cryptographically identifiable **Token Family**.

A token family represents a single persistent user login session. It is identified by a unique Session Identifier (`sid` or `familyId`) created when the user completes primary authentication (password, OAuth, or 2FA).

Every refresh token payload carries three critical claims:

-   `sub`: The subject (User ID).
    
-   `jti`: A unique token identifier (UUID or cryptographically random hex string).
    
-   `sid`: The immutable Token Family ID for this physical device/session.
    

```typescript
export interface VerifiedRefreshSessionPayload {
  sub: string;
  type: 'refresh';
  jti: string;       // Unique ID for this specific token
  sid?: string;      // Immutable Family / Session ID
  iat?: number;      // Issued at
  av?: number;       // Global Account Auth Version
  exp: number;       // Absolute expiration timestamp
}

```

### The State Transition Tree

When Token A is exchanged, it produces Token B, which belongs to the same `sid`. When Token B is exchanged, it produces Token C.

![Mermaid Diagram](https://mermaid.ink/img/pako:eNp1kcFu2zAMhl-F5WHoAHmY0x0KHwqkiYMG2HZIlF2ioGBsOtEiS4GkpcuavEgvve7x-giF49bbob2RxM-PP8l7LFzJmGFl3F2xJh9BDpUFAPjqVtrOFc4C-yYJMLYKF5AkVyDTuULpNmyhD-c_o84g_ZwKCLrMoKL69vLyo8JFC5Jp03P47nxNBiZceQ7rA8hex7juGL33GL23GRcdY9AxLt5h_HPzKbk69GOkYsMeJrw1tA_wss4B-oZ8PVf49PjwF-RNPpIwzGU-kPnwrIOdRKdbTHjnNjxX2AaQ26g9w4hqbfZw_qaVF2nTPuUd-_8u8QFe9xnbEMlGs281XCpcoMCafU26xOwe45rr5nklV_TLRBRt5Qd5TUvDodFUzsbWC2aY0HZrOAn7ELkWcG203XyjYnrKR85GAQqnvHIMs7FCARO3dNEJuGGz46gLEtD3moyAQDYkgb2uUJyGTPWfxkv6Zfsbj0eBy9XAGecxw7O7tY6Mx2e-hsUO?type=png)

If the server ever encounters a request presenting a `jti` that has **already been used**, it means two entities hold copies of the same credential. The server does not attempt to guess who the legitimate owner is; it marks the entire family (`rt:family:fam_88:revoked`) as compromised.

Both the attacker and the victim are immediately logged out, halting further unauthorized actions.

* * *

## 3\. The Concurrency Problem & The Grace Window

Why doesn't everyone implement this? Because without atomic concurrency controls, **it makes your application feel fundamentally broken**.

Consider modern web and mobile clients:

-   A dashboard page loads five API widgets concurrently via `Promise.all()`.
    
-   Each widget's HTTP client encounters an expired access token at the exact same millisecond.
    
-   Two or three concurrent refresh calls hit the backend before any client receives the replacement `Set-Cookie`.
    

If your rotation logic naively executes:

```sql
UPDATE refresh_tokens SET used = true WHERE id = :jti;

```

...the second concurrent request sees `used == true` and fires the nuclear option: revoking the user's family and logging them out while they are actively looking at your dashboard.

### The Solution: Micro-Grace Windows

Instead of immediate revocation on reuse, we establish a **bounded grace window** (typically 3 to 10 seconds):

1.  **First Request:** Consumes the old token (`jti_old`), generates `jti_new`, and caches `jti_new` in Redis keyed by `rt:grace:jti_old` with a 5-second TTL.
    
2.  **Concurrent Request (< 5s):** If a request arrives with `jti_old` within 5 seconds of its initial rotation, the server **does not rotate again** and **does not trigger an alarm**. Instead, it returns the _exact same_ replacement token cached in `rt:grace:jti_old`.
    
3.  **Replay Outside Grace (> 5s):** If `jti_old` arrives after 5 seconds, this cannot be a network race or browser concurrency. It is an active replay attack. The server revokes the entire `sid` family.
    

* * *

## 4\. Why You Must Use Redis Lua Scripts

Executing this in multi-statement SQL or multiple Node.js Redis calls creates a disastrous race condition (Check-Then-Act):

```typescript
// ❌ BROKEN: Vulnerable to race conditions between get and set
const isUsed = await redis.get(`rt:used:${jti}`);
if (isUsed) {
  // Two concurrent requests can both reach here!
  await redis.set(`rt:family:${sid}:revoked`, '1');
  return { status: 'replay' };
}
await redis.set(`rt:used:${jti}`, Date.now());

```

In a distributed environment with multiple API replicas, two identical requests running concurrently will both read `isUsed == null`, both generate two different replacement tokens, and leave the client in an inconsistent split-brain state.

All state transitions must be **completely atomic**. We achieve this with a single Redis Lua script that checks revocation, claims the token, verifies the grace window, and handles family revocation in one execution block.

### The Atomic Lua Script

```lua
-- KEYS[1] = rt:used:<jti>
-- KEYS[2] = rt:grace:<jti>
-- KEYS[3] = rt:family:<sessionId>:revoked
-- KEYS[4] = rt:blacklist:<jti>

-- ARGV[1] = usedTokenTtlSeconds
-- ARGV[2] = replacementToken
-- ARGV[3] = nowMilliseconds
-- ARGV[4] = reuseGraceMilliseconds
-- ARGV[5] = familyTtlSeconds

-- 1. Check if the session family or specific token is already revoked
if redis.call('EXISTS', KEYS[3]) == 1 or redis.call('EXISTS', KEYS[4]) == 1 then
  return { 'revoked', '' }
end

-- 2. Check if this token was previously consumed
local existing = redis.call('GET', KEYS[1])
if not existing then
  -- First time use: Claim the token
  redis.call('SET', KEYS[1], ARGV[3], 'EX', ARGV[1])
  
  -- Cache the replacement token for concurrent grace requests
  if tonumber(ARGV[4]) > 0 then
    redis.call('SET', KEYS[2], ARGV[2], 'PX', ARGV[4])
  end
  
  return { 'rotated', ARGV[2] }
end

-- 3. Token was already used: Check if we are within the concurrency grace window
local age = tonumber(ARGV[3]) - tonumber(existing)
local graceReplacement = redis.call('GET', KEYS[2])
if graceReplacement and age >= 0 and age <= tonumber(ARGV[4]) then
  -- Legitimate concurrency race: Return the exact same replacement token
  return { 'grace', graceReplacement }
end

-- 4. Replay detected outside grace window: Revoke the ENTIRE token family!
redis.call('SET', KEYS[3], '1', 'EX', ARGV[5])
return { 'replay', '' }

```

Let's trace the four potential outcomes of this script:

| Status | Condition | Action Taken |
| --- | --- | --- |
| 'rotated' | Token never seen before | Records usage timestamp, stores replacement in grace cache, returns new token. |
| 'grace' | Token used within last 5s | Returns previously issued replacement token. No new token generated; no alarm. |
| 'replay' | Token reused after 5s | Flags security breach. Marks family as revoked (KEYS[3] = 1). Returns rejection. |
| 'revoked' | Family or token already revoked | Instantly terminates. No processing allowed. |

* * *

## 5\. Implementing the Backend Engine in NestJS

Let's assemble the production implementation across three modules:

1.  `refresh-session.ts` — Type definitions, key formatting, and payload parsing.
    
2.  `redis.service.ts` — The atomic Lua rotation method.
    
3.  `auth.service.ts` & `auth.resolver.ts` — The rotation workflow and cookie management.
    

### Step 1: Session Payloads and Keys (`refresh-session.ts`)

```typescript
import { createHash } from 'node:crypto';

export interface VerifiedRefreshSessionPayload {
  sub: string;
  type: 'refresh';
  jti: string;
  sid?: string;
  iat?: number;
  av?: number;
  exp: number;
}

export function parseRefreshSessionPayload(value: unknown): VerifiedRefreshSessionPayload | null {
  if (!value || typeof value !== 'object') return null;
  const payload = value as any;

  if (
    payload.type !== 'refresh' ||
    typeof payload.sub !== 'string' ||
    typeof payload.jti !== 'string' ||
    typeof payload.exp !== 'number'
  ) {
    return null;
  }

  return {
    sub: payload.sub,
    type: 'refresh',
    jti: payload.jti,
    sid: payload.sid || `legacy-${createHash('sha256').update(payload.jti).digest('hex')}`,
    iat: payload.iat,
    av: payload.av ?? 0,
    exp: payload.exp,
  };
}

export const refreshTokenUsedKey = (jti: string) => `rt:used:${jti}`;
export const refreshTokenReplacementGraceKey = (jti: string) => `rt:grace:${jti}`;
export const refreshTokenFamilyKey = (sessionId: string) => `rt:family:${sessionId}:revoked`;
export const refreshTokenBlacklistKey = (jti: string) => `rt:blacklist:${jti}`;

```

> \[!NOTE\] **Backward Compatibility:** Notice how `parseRefreshSessionPayload` automatically derives a deterministic `legacy-<hash>` family ID if an older refresh token lacks a `sid`. This ensures that updating your authentication server doesn't invalidate existing user sessions in the wild.

* * *

### Step 2: The Atomic Redis Service (`redis.service.ts`)

```typescript
import { Injectable, Logger } from '@nestjs/common';
import Redis from 'ioredis';

export type TokenRotationStatus = 'rotated' | 'grace' | 'replay' | 'revoked';

export interface TokenRotationResult {
  status: TokenRotationStatus;
  replacementToken?: string;
}

export interface RotateTokenAtomicOptions {
  usedTokenKey: string;
  replacementGraceKey: string;
  revokedFamilyKey: string;
  blacklistedTokenKey: string;
  replacementToken: string;
  usedTokenTtlSeconds: number;
  familyTtlSeconds: number;
  reuseGraceMilliseconds: number;
  nowMilliseconds?: number;
}

@Injectable()
export class RedisService {
  private readonly logger = new Logger(RedisService.name);
  private client: Redis;

  async rotateTokenAtomic(options: RotateTokenAtomicOptions): Promise<TokenRotationResult> {
    const nowMilliseconds = options.nowMilliseconds ?? Date.now();
    const usedTokenTtlSeconds = Math.max(1, Math.floor(options.usedTokenTtlSeconds));
    const familyTtlSeconds = Math.max(1, Math.floor(options.familyTtlSeconds));

    const script = `
      if redis.call('EXISTS', KEYS[3]) == 1 or redis.call('EXISTS', KEYS[4]) == 1 then
        return { 'revoked', '' }
      end

      local existing = redis.call('GET', KEYS[1])
      if not existing then
        redis.call('SET', KEYS[1], ARGV[3], 'EX', ARGV[1])
        if tonumber(ARGV[4]) > 0 then
          redis.call('SET', KEYS[2], ARGV[2], 'PX', ARGV[4])
        end
        return { 'rotated', ARGV[2] }
      end

      local age = tonumber(ARGV[3]) - tonumber(existing)
      local graceReplacement = redis.call('GET', KEYS[2])
      if graceReplacement and age >= 0 and age <= tonumber(ARGV[4]) then
        return { 'grace', graceReplacement }
      end

      redis.call('SET', KEYS[3], '1', 'EX', ARGV[5])
      return { 'replay', '' }
    `;

    const raw = (await this.client.eval(
      script,
      4,
      options.usedTokenKey,
      options.replacementGraceKey,
      options.revokedFamilyKey,
      options.blacklistedTokenKey,
      String(usedTokenTtlSeconds),
      options.replacementToken,
      String(nowMilliseconds),
      String(Math.max(0, options.reuseGraceMilliseconds)),
      String(familyTtlSeconds),
    )) as [TokenRotationStatus, string];

    const status = raw[0];
    return {
      status,
      replacementToken: (status === 'rotated' || status === 'grace') ? raw[1] : undefined,
    };
  }
}

```

* * *

### Step 3: Auth Service Rotation Logic (`auth.service.ts`)

```typescript
@Injectable()
export class AuthService {
  constructor(
    private readonly jwt: JwtService,
    private readonly redis: RedisService,
    private readonly config: ConfigService,
  ) {}

  async rotateRefreshToken(
    payload: VerifiedRefreshSessionPayload,
    authVersion: number,
  ): Promise<TokenRotationResult> {
    const nowSeconds = Math.floor(Date.now() / 1000);
    const currentTtlSeconds = Math.max(1, payload.exp - nowSeconds);
    const sessionId = payload.sid!;

    // Generate the speculative replacement token
    const replacementToken = await this.createRefreshToken(payload.sub, sessionId, authVersion);
    const replacementPayload = parseRefreshSessionPayload(this.jwt.decode(replacementToken));

    if (!replacementPayload) {
      throw new Error('Failed to create valid replacement token');
    }

    const graceSeconds = 5; // 5-second grace window for concurrent requests

    return this.redis.rotateTokenAtomic({
      usedTokenKey: refreshTokenUsedKey(payload.jti),
      replacementGraceKey: refreshTokenReplacementGraceKey(payload.jti),
      revokedFamilyKey: refreshTokenFamilyKey(sessionId),
      blacklistedTokenKey: refreshTokenBlacklistKey(payload.jti),
      replacementToken,
      usedTokenTtlSeconds: currentTtlSeconds,
      familyTtlSeconds: Math.max(1, replacementPayload.exp - nowSeconds),
      reuseGraceMilliseconds: graceSeconds * 1000,
    });
  }
}

```

* * *

## 6\. Edge Case: The Next.js SSR / Server Component Problem

Modern web frameworks like Next.js App Router execute asynchronous data fetching inside **React Server Components (RSC)**.

When a page renders on the server:

1.  The server component reads the cookies sent by the browser.
    
2.  If the user's access token is expired, the server component needs a fresh access token to fetch data from the internal backend API.
    
3.  **The problem:** Server Components in Next.js cannot invoke `cookies().set()` to mutate browser cookies while rendering HTML streaming chunks!
    

If your server component executes a standard refresh request that rotates the token in Redis, but cannot send the replacement `Set-Cookie` header back to the browser, **the browser's cookie will still contain the old token on the next client-side fetch**, triggering an immediate false-positive theft alarm!

### The Two-Tier Refresh Architecture

To solve this, we distinguish between **Browser Refreshes** and **Trusted Server Refreshes**:

![Mermaid Diagram](https://mermaid.ink/img/pako:eNptUt9v2jAQ_lduftqkoK5Sn6KpE6VD5WF0SmilCXgwzgfxcHzMvpQy4H-vksBQpb6c5fPd90veK8MFVKqWjrem1EFocj_zREQZ_k5H3nBl_YoyLANi2TRrRJlTr3dLgxJm3a-l3I8iTUIdBQXlCC8IlMMECP0KiPDy_dhhdvX_XoNy-I2YnrcGXG3YwwtdUZ5nh6YMHW-nMzUJ2kfbPPWNQYw04TU8jWKsUXxbhKvbz2eRA-a1BY0fJ5SxaEHxZabmH3GPOaWBa1HvAm8jAg0hpjycryfyXLQvdCioL1xZ08Fa9vTjFaaWi4CN0wZVg3cSkUMu5F09eWojfEawy91TRLF_0JEWJxHaBehi16KGzgJJaSNJY_qc5mX5HOSBMvyBkWl30M3X6_mHs2M-dNFNM0gd_PtQH73bzVWiKoRK20KleyUlquafFFjq2olKus6zDlYvHGIzs2QvQ11Zt1Op6unNxqEXd1FQJXTnrF__1CZv70P2ktBM5Vgx6Gk0UwllvGDhhB7gXiDW6IT6wWqXUNQ-9mJjQCUtSW7_NVqubzav6nhM1GI1YMdBperTtrQCdXwDzgn4-Q?type=png)

### The Timing-Safe Server Verification

```typescript
import { createHash, timingSafeEqual } from 'node:crypto';

export function isTrustedServerRefresh(
  providedHeader: string | string[] | undefined,
  expectedSecret: string | undefined,
): boolean {
  if (!expectedSecret) return false;
  const provided = Array.isArray(providedHeader) ? providedHeader[0] : providedHeader;
  if (!provided) return false;

  // Protect against timing attacks using SHA-256 digests of equal length
  const providedDigest = createHash('sha256').update(provided).digest();
  const expectedDigest = createHash('sha256').update(expectedSecret).digest();
  return timingSafeEqual(providedDigest, expectedDigest);
}

```

In your refresh resolver/controller:

```typescript
const isServerRendering = isTrustedServerRefresh(
  req.headers['x-internal-server-auth'],
  process.env.INTERNAL_BUILD_SECRET,
);

if (isServerRendering) {
  // Guard: Server rendering must never revive an already-rotated browser token
  if (await this.auth.isRefreshTokenUsed(payload.jti)) {
    this.clearRefreshCookie(res);
    return { accessToken: '' };
  }
  // Server rendering receives a short-lived access token WITHOUT rotating the refresh cookie
} else {
  // Standard browser interaction: execute full atomic rotation and update cookies
  const rotation = await this.auth.rotateRefreshToken(payload, user.authVersion);
  if (!rotation.replacementToken) {
    this.clearRefreshCookie(res);
    return { accessToken: '' };
  }
  this.setRefreshCookie(res, rotation.replacementToken);
}

```

* * *

## 7\. Global Logout Across All Devices: The `authVersion` Pattern

What if a user clicks **"Log out of all devices"** or **resets their password**?

In a naive Redis setup, you would have to scan and delete thousands of Redis keys matching `rt:*`. If Redis restarts or drops memory under pressure, those sessions could theoretically revive.

Instead, we use a monotonic integer on the user record in PostgreSQL: `authVersion`.

```sql
ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0;

```

1.  Every refresh token embeds the user's current `authVersion` at creation time:
    
    ```json
    {
      "sub": "usr_99182",
      "jti": "tok_abc123",
      "sid": "fam_xyz789",
      "av": 3,
      "exp": 1727884800
    }
    
    ```
    
2.  When the user clicks "Log out of all devices":
    
    ```sql
    UPDATE users SET auth_version = auth_version + 1 WHERE id = :userId;
    
    ```
    
3.  When `/refresh` evaluates the token:
    
    ```typescript
    if ((payload.av ?? 0) !== user.authVersion) {
      this.clearRefreshCookie(res);
      return { accessToken: '' }; // Instantly invalidates all previous refresh tokens!
    }
    
    ```
    

This provides **instantaneous, O(1) global session invalidation** across infinite devices without placing high read/write loads on Redis.

* * *

## 8\. Verifying the System: Automated Unit Tests

Here is the exact Node.js test suite proving both concurrency protection and theft detection under real-world timing simulations:

```typescript
import { equal } from 'node:assert/strict';
import { test } from 'node:test';
import { RedisService } from '../src/common/redis/redis.service';

test('concurrency grace window returns identical replacement token to concurrent tabs', async () => {
  const redis = new RedisService();

  const options = {
    usedTokenKey: 'rt:used:tok_100',
    replacementGraceKey: 'rt:grace:tok_100',
    revokedFamilyKey: 'rt:family:fam_1:revoked',
    blacklistedTokenKey: 'rt:blacklist:tok_100',
    replacementToken: 'tok_200',
    usedTokenTtlSeconds: 3600,
    familyTtlSeconds: 7200,
    reuseGraceMilliseconds: 5000,
    nowMilliseconds: 10_000,
  };

  // Tab 1 hits refresh at t = 10,000ms
  const first = await redis.rotateTokenAtomic(options);
  
  // Tab 2 hits refresh 2 seconds later (t = 12,000ms) with the same old token
  const concurrent = await redis.rotateTokenAtomic({
    ...options,
    replacementToken: 'tok_candidate_rejected',
    nowMilliseconds: 12_000,
  });

  equal(first.status, 'rotated');
  equal(first.replacementToken, 'tok_200');

  // Tab 2 safely receives Tab 1's token instead of crashing!
  equal(concurrent.status, 'grace');
  equal(concurrent.replacementToken, 'tok_200');
});

test('reuse outside the grace window revokes the whole family', async () => {
  const redis = new RedisService();

  const options = {
    usedTokenKey: 'rt:used:tok_100',
    replacementGraceKey: 'rt:grace:tok_100',
    revokedFamilyKey: 'rt:family:fam_1:revoked',
    blacklistedTokenKey: 'rt:blacklist:tok_100',
    replacementToken: 'tok_200',
    usedTokenTtlSeconds: 3600,
    familyTtlSeconds: 7200,
    reuseGraceMilliseconds: 5000,
    nowMilliseconds: 10_000,
  };

  // Legitimate rotation occurs at t = 10,000ms
  await redis.rotateTokenAtomic(options);

  // Attacker replays tok_100 at t = 16,000ms (6 seconds later, past 5s grace)
  const replay = await redis.rotateTokenAtomic({
    ...options,
    nowMilliseconds: 16_000,
  });

  equal(replay.status, 'replay');

  // Legitimate client tries to use the replacement token tok_200
  const descendant = await redis.rotateTokenAtomic({
    ...options,
    usedTokenKey: 'rt:used:tok_200',
    replacementGraceKey: 'rt:grace:tok_200',
    blacklistedTokenKey: 'rt:blacklist:tok_200',
    nowMilliseconds: 17_000,
  });

  // The entire family is revoked! Attacker and victim are both severed.
  equal(descendant.status, 'revoked');
});

```

* * *

## 9\. Architectural Takeaways & Security Checklist

When building authentication for real-world production users, simple tutorials leave massive blind spots. Implementing Refresh Token Rotation correctly requires respecting the physical realities of asynchronous web clients and distributed infrastructure:

1.  **Tokens Must Belong to Families:** Never track refresh tokens in isolation. Group tokens by persistent login sessions (`sid`) so theft triggers holistic revocation.
    
2.  **State Transitions Must Be Atomic:** Use Redis Lua scripts (`eval`) to ensure check-claim-grace operations execute as a single atomic CPU cycle.
    
3.  **Always Include a Micro-Grace Window:** A 3-to-5-second grace window eliminates 100% of false logouts caused by multi-tab browser startup or mobile connection retries.
    
4.  **Isolate Server Components (SSR):** Never rotate browser refresh cookies inside read-only server component renders. Provide a trusted server-side authentication channel.
    
5.  **Use Monotonic Auth Versions:** Keep global session termination (`logout all devices`) instant and stateless using database-level `authVersion` counters.
    
6.  **Protect Against Timing Attacks:** Use `crypto.timingSafeEqual()` on SHA-256 hashes whenever validating server-to-server authentication headers.
    

By combining atomic Redis operations with token family revocation, you get the gold standard of modern API security: **instant breach containment without sacrificing user experience.**

---

*Published via [ZyVOP](https://zyvop.com/stop-logging-out-innocent-users-building-production-refresh-token-rotation-with-token-family-revocation-in-redis-qch9v?utm_source=hashnode&utm_medium=crosspost&utm_campaign=syndication) — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium & Hashnode in 1 click.*
