JWT Access and Refresh Token Rotation — Complete Token Lifecycle Management
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
- What is refresh token rotation?
- How does a token family enable theft detection?
- Why must refresh tokens be hashed in storage?
- What happens when two refresh requests arrive simultaneously?
- What is the maximum lifetime of a token family?
Answers:
- 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.
- 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.
- If the database is compromised, hashed refresh tokens cannot be used directly. The attacker would find them useless for authentication.
- Both may succeed if the server does not implement a lock. The second refresh invalidates the first's new token. Implement atomic token rotation.
- 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
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