Skip to content

JWT Access and Refresh Token Rotation — Complete Token Lifecycle Management

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about JWT Access and Refresh Token Rotation. We cover key concepts, practical examples, and best practices to help you master this topic.

JWT access token rotation replaces refresh tokens on each use, creating a token family where compromise of one refresh token does not grant long-term access.

What You'll Learn

How to implement refresh token rotation with token families, detect token theft, handle concurrent refresh requests, and build automatic token renewal.

Why It Matters

Without rotation, a stolen refresh token grants permanent access. Rotation limits the damage window: each use creates a new token, and the old one becomes invalid. Theft is detected when a revoked token is presented.

Real-World Use

Auth0 implements refresh token rotation by default. Google and Microsoft use rotation for their OAuth2 implementations. Durga Antivirus Pro uses rotation for partner API tokens with automatic theft detection alerts.

sequenceDiagram
    Client->>Server: POST /login (credentials)
    Server->>Client: { access_token (15m), refresh_token (R1) }
    Note over Client,Server: Access token expires
    Client->>Server: POST /refresh (refresh_token=R1)
    Server->>Server: Validate R1, issue R2 + new access
    Server->>Client: { access_token, refresh_token (R2) }
    Note over Client,Server: Attack scenario
    Attacker->>Server: POST /refresh (refresh_token=R1)
    Server->>Server: R1 already used! Theft detected
    Server->>Server: Revoke entire token family
    Server->>Attacker: 401 + "Token family revoked"

Code Example: Refresh Token Rotation with Token Families

import jwt, secrets, datetime, hashlib
from flask import Flask, request, jsonify

app = Flask(__name__)
SECRET = "rotation-secret"

# token_families[family_id] = { active_token_hash, user, revoked }
token_families = {}

def create_token_family(user):
    family_id = secrets.token_urlsafe(16)
    refresh_token = secrets.token_urlsafe(32)
    token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()

    token_families[family_id] = {
        "user": user,
        "active_hash": token_hash,
        "revoked": False,
        "created": datetime.datetime.utcnow().isoformat()
    }
    return family_id, refresh_token

def issue_access_token(user):
    return jwt.encode({
        "sub": user,
        "iat": datetime.datetime.utcnow(),
        "exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=15)
    }, SECRET, algorithm="HS256")

@app.route("/api/auth/login", methods=["POST"])
def login():
    username = request.json.get("username", "user")
    family_id, refresh_token = create_token_family(username)
    access_token = issue_access_token(username)

    return jsonify({
        "access_token": access_token,
        "refresh_token": refresh_token,
        "token_family": family_id,
        "expires_in": 900
    })

@app.route("/api/auth/refresh", methods=["POST"])
def refresh():
    old_refresh = request.json.get("refresh_token")
    if not old_refresh:
        return jsonify({"error": "Refresh token required"}), 401

    old_hash = hashlib.sha256(old_refresh.encode()).hexdigest()

    # Find token family
    for fam_id, fam in token_families.items():
        if fam["active_hash"] == old_hash:
            if fam["revoked"]:
                return jsonify({
                    "error": "Token family revoked — possible theft"
                }), 401

            # Issue new tokens, invalidate old
            new_refresh = secrets.token_urlsafe(32)
            new_hash = hashlib.sha256(new_refresh.encode()).hexdigest()
            fam["active_hash"] = new_hash

            access_token = issue_access_token(fam["user"])

            return jsonify({
                "access_token": access_token,
                "refresh_token": new_refresh,
                "expires_in": 900
            })

    return jsonify({"error": "Invalid refresh token"}), 401

Code Example: Theft Detection Logic

@app.route("/api/auth/refresh", methods=["POST"])
def refresh_with_theft_detection():
    """Enhanced refresh with automatic theft detection."""
    old_refresh = request.json.get("refresh_token")
    old_hash = hashlib.sha256(old_refresh.encode()).hexdigest()

    for fam_id, fam in token_families.items():
        if fam["active_hash"] == old_hash:
            # Normal rotation — issue new tokens
            new_refresh = secrets.token_urlsafe(32)
            new_hash = hashlib.sha256(new_refresh.encode()).hexdigest()
            fam["active_hash"] = new_hash

            return jsonify({
                "access_token": issue_access_token(fam["user"]),
                "refresh_token": new_refresh
            })

        # Check if old_hash matches a PREVIOUS hash in this family
        if fam.get("previous_hashes") and old_hash in fam["previous_hashes"]:
            # Theft detected! This refresh was already rotated
            fam["revoked"] = True
            fam["theft_detected_at"] = datetime.datetime.utcnow().isoformat()

            # Alert security team (in production)
            alert_theft_detected(fam_id, fam["user"])

            return jsonify({
                "error": "Possible token theft — all tokens revoked",
                "contact": "security@dodatech.com"
            }), 401

    return jsonify({"error": "Invalid refresh token"}), 401


def alert_theft_detected(family_id, user):
    """Log security event for token theft."""
    import logging
    logging.warning(
        f"TOKEN THEFT DETECTED: family={family_id}, user={user}, "
        f"time={datetime.datetime.utcnow().isoformat()}"
    )

Code Example: Client-Side Automatic Token Renewal

class AuthenticatedClient {
  constructor(baseURL) {
    this.baseURL = baseURL;
    this.accessToken = null;
    this.refreshToken = null;
    this.refreshPromise = null;
  }

  async request(path, options = {}) {
    const headers = { ...options.headers };
    if (this.accessToken) {
      headers['Authorization'] = `Bearer ${this.accessToken}`;
    }

    const resp = await fetch(`${this.baseURL}${path}`, {
      ...options, headers
    });

    if (resp.status === 401 && this.refreshToken) {
      await this.attemptRefresh();
      return this.request(path, options); // Retry
    }

    return resp;
  }

  async attemptRefresh() {
    // Prevent concurrent refresh storms
    if (this.refreshPromise) return this.refreshPromise;

    this.refreshPromise = (async () => {
      const resp = await fetch(`${this.baseURL}/api/auth/refresh`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ refresh_token: this.refreshToken })
      });

      if (resp.ok) {
        const data = await resp.json();
        this.accessToken = data.access_token;
        this.refreshToken = data.refresh_token;
      } else {
        this.accessToken = null;
        this.refreshToken = null;
        throw new Error('Session expired');
      }
    })();

    try {
      return await this.refreshPromise;
    } finally {
      this.refreshPromise = null;
    }
  }
}

Common Mistakes

1. Not Using Token Families

Without a family identifier, the server cannot detect that a stolen refresh token was used after rotation. Track each token's lineage.

2. No Concurrent Refresh Handling

Two simultaneous refresh requests may both succeed if not handled. Use a mutex or short-lived lock during refresh to prevent race conditions.

3. Storing Refresh Tokens in Plaintext

Hash refresh tokens with SHA-256 before storing. If the database is compromised, hashed tokens cannot be used to authenticate.

4. Not Invalidating All Family Tokens on Theft

When theft is detected, revoke the entire token family. The attacker should not be able to authenticate with any previously issued token.

5. Refresh Token Never Expires

Even with rotation, set a maximum lifetime on the token family (e.g., 30 days). After this period, the user must re-authenticate.

Practice Questions

  1. What is refresh token rotation?
  2. How does a token family enable theft detection?
  3. Why must refresh tokens be hashed in storage?
  4. What happens when two refresh requests arrive simultaneously?
  5. What is the maximum lifetime of a token family?

Answers:

  1. Each time a refresh token is used, the server issues a new refresh token and invalidates the old one. The old token cannot be used again.
  2. Each token family has a chain of hashes. If a previously valid hash (but not the current one) is presented, the server knows the old token was stolen.
  3. If the database is compromised, hashed refresh tokens cannot be used directly. The attacker would find them useless for authentication.
  4. Both may succeed if the server does not implement a lock. The second refresh invalidates the first's new token. Implement atomic token rotation.
  5. Typically 30-90 days. After this, the user must re-authenticate with their primary credentials.

Challenge: Implement refresh token rotation with token families in Flask, including theft detection, concurrent request handling, and security logging.

FAQ

Is refresh token rotation mandatory?

Not mandatory, but strongly recommended for high-security APIs. Without rotation, a stolen refresh token is valid until it expires.

How long should access tokens live with rotation?

5-15 minutes. Short access tokens reduce the window of risk. The refresh token handles getting new access tokens transparently.

What happens to in-progress requests during rotation?

The client should queue requests during refresh. Once the new token is obtained, queued requests are retried with the new token.

Does rotation work with offline-first apps?

Yes, but design carefully. Store the current refresh token securely. When the app comes online, refresh the token before making API calls.

How do I revoke all tokens for a user?

Delete or mark all token families belonging to that user as revoked. The user must re-authenticate on their next request.

What is the overhead of rotation?

Minimal. One additional database write per refresh. Hashing and comparison are sub-millisecond operations.

Mini Project

Build a complete token rotation system with a Flask backend and JavaScript client. Include login, automatic refresh on 401, theft detection, concurrent request handling, and a test script that simulates token theft.

What's Next

Now learn about JWT Automatic Renewal for transparent token management without user interruption.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro