Token Refresh Patterns — Complete Implementation Guide
In this tutorial, you will learn about Token Refresh Patterns. We cover key concepts, practical examples, and best practices to help you master this topic.
Token refresh patterns enable applications to maintain user sessions beyond the lifetime of a short-lived access token by using a longer-lived refresh token to obtain new access tokens without requiring the user to re-authenticate.
What You'll Learn
By the end of this lesson, you will implement token refresh with rotation, build automatic refresh interceptors for API clients, manage refresh token lifecycle, and detect refresh token theft.
Why It Matters
Short-lived access tokens (15 minutes) limit the damage if a token is stolen. Refresh tokens make the short lifetime practical by allowing seamless token renewal. Doda Browser uses refresh tokens to maintain user sessions across its sync service while keeping access tokens short-lived for security.
Real-World Use
A user opens a banking app in the morning. The app obtains an access token (valid 15 min) and a refresh token (valid 7 days). The user checks their balance, then puts the phone down. 20 minutes later, they open the app again. The app automatically refreshes the access token using the refresh token. The user never sees a login prompt.
Token Refresh Flow
sequenceDiagram
participant Client
participant API
participant Auth
Client->>Auth: Login
Auth-->>Client: Access Token (15m) + Refresh Token (7d)
Client->>API: API call with Access Token
API-->>Client: 200 OK
Note over Client,API: 15 minutes later...
Client->>API: API call with expired Access Token
API-->>Client: 401 Unauthorized
Client->>Auth: POST /refresh with Refresh Token
Auth-->>Client: New Access Token + New Refresh Token (rotated)
Client->>API: Retry API call with new Access Token
API-->>Client: 200 OK
Refresh Token with Rotation
const express = require("express");
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const app = express();
app.use(express.json());
const ACCESS_SECRET = crypto.randomBytes(64).toString("hex");
const REFRESH_SECRET = crypto.randomBytes(64).toString("hex");
const tokenFamily = new Map();
function generateTokens(user) {
const accessToken = jwt.sign(
{ sub: user.id, role: user.role, type: "access" },
ACCESS_SECRET,
{ expiresIn: "15m" }
);
const refreshId = crypto.randomBytes(16).toString("hex");
const refreshToken = jwt.sign(
{ sub: user.id, type: "refresh", refreshId, family: user.id },
REFRESH_SECRET,
{ expiresIn: "7d" }
);
tokenFamily.set(refreshId, {
refreshId,
userId: user.id,
createdAt: Date.now(),
isUsed: false,
});
return { accessToken, refreshToken, expiresIn: 900 };
}
app.post("/api/auth/login", (req, res) => {
const { email, password } = req.body;
if (email !== "alice@example.com" || password !== "correct-password") {
return res.status(401).json({ error: "Invalid credentials" });
}
const tokens = generateTokens({ id: 1, role: "user" });
res.json(tokens);
});
app.post("/api/auth/refresh", (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: "Refresh token required" });
}
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
const stored = tokenFamily.get(decoded.refreshId);
if (!stored) {
return res.status(401).json({ error: "Refresh token not found" });
}
if (stored.isUsed) {
console.log(`[TOKEN THEFT] Refresh token ${decoded.refreshId} already used!`);
const familyMembers = [...tokenFamily.values()]
.filter(t => t.userId === decoded.sub && !t.isUsed);
familyMembers.forEach(t => {
t.isUsed = true;
console.log(`[TOKEN THEFT] Invalidating sibling token: ${t.refreshId}`);
});
return res.status(401).json({ error: "Token reuse detected" });
}
stored.isUsed = true;
const tokens = generateTokens({ id: decoded.sub, role: decoded.role || "user" });
console.log(`[Refresh] Tokens rotated for user ${decoded.sub}`);
res.json(tokens);
} catch (err) {
res.status(401).json({ error: "Invalid refresh token" });
}
});
app.listen(3000);
Automatic Refresh Interceptor
// API client with automatic token refresh
class ApiClient {
constructor(baseURL, authService) {
this.baseURL = baseURL;
this.authService = authService;
this.accessToken = null;
this.refreshPromise = null;
}
async request(endpoint, options = {}) {
if (!this.accessToken) {
throw new Error("Not authenticated");
}
const response = await fetch(`${this.baseURL}${endpoint}`, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${this.accessToken}`,
},
});
if (response.status === 401) {
const newToken = await this.refreshAccessToken();
this.accessToken = newToken;
return this.request(endpoint, options);
}
return response.json();
}
async refreshAccessToken() {
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = this.authService.refresh()
.then((token) => {
this.refreshPromise = null;
return token;
})
.catch((err) => {
this.refreshPromise = null;
this.accessToken = null;
throw err;
});
return this.refreshPromise;
}
}
Common Mistakes
- Not rotating refresh tokens leaves a stale token valid indefinitely if stolen.
- Failing to detect refresh token reuse allows attackers to maintain access after theft.
- Storing refresh tokens in localStorage exposes them to XSS attacks.
- Using the same secret for access and refresh tokens allows cross-use.
- Not implementing a maximum refresh chain length allows infinite session extension.
- Blocking the UI while refreshing tokens creates poor user experience.
Practice Questions
- What is refresh token rotation?
Each time the client refreshes, the server issues a new refresh token and invalidates the old one. If an attacker steals a refresh token, the next legitimate use will fail because the token was already rotated, alerting of theft.
- How do you detect refresh token theft?
Implement token family tracking. When a used refresh token is presented, invalidate all tokens in the same family. The legitimate user will be forced to re-authenticate, and the attacker loses access.
- What is the maximum safe lifetime for a refresh token?
7-30 days depending on security requirements. Banking apps: 7 days. Social media: 30 days. Enterprise apps: configurable by admin policy. Always allow absolute timeout regardless of refresh activity.
- Challenge: Implement a complete token refresh system with rotation, theft detection, maximum refresh chain (20 refreshes max), device binding (refresh token tied to device fingerprint), and automatic cleanup of expired token records.
FAQ
Mini Project: Token Refresh Monitor
Build a CLI tool that simulates a client performing API calls with token refresh, tracks refresh events, and generates a report on token lifetime and refresh patterns.
import time
import jwt
import secrets
from datetime import datetime, timedelta
class TokenSimulator:
def __init__(self):
self.secret = secrets.token_hex(32)
self.refresh_count = 0
self.events = []
def create_tokens(self):
access = jwt.encode({
"sub": "user_1", "type": "access",
"exp": int(time.time()) + 15,
}, self.secret, algorithm="HS256")
refresh = jwt.encode({
"sub": "user_1", "type": "refresh", "id": secrets.token_hex(8),
"exp": int(time.time()) + 300,
}, self.secret, algorithm="HS256")
return access, refresh
def simulate_session(self, duration_minutes=10):
access, refresh = self.create_tokens()
start = time.time()
end = start + duration_minutes * 60
now = start
while now < end:
try:
jwt.decode(access, self.secret, algorithms=["HS256"])
self.events.append({"time": datetime.utcnow(), "event": "API call OK"})
except jwt.ExpiredSignatureError:
try:
jwt.decode(refresh, self.secret, algorithms=["HS256"])
access, refresh = self.create_tokens()
self.refresh_count += 1
self.events.append({"time": datetime.utcnow(), "event": f"Token refreshed (#{self.refresh_count})"})
except jwt.ExpiredSignatureError:
self.events.append({"time": datetime.utcnow(), "event": "Session expired"})
break
now += 20
print(f"=== Session Report ===")
print(f"Duration: {duration_minutes} minutes simulated")
print(f"API calls: {len(self.events)}")
print(f"Refreshes: {self.refresh_count}")
print(f"Active session: {'Yes' if self.events and self.events[-1]['event'] != 'Session expired' else 'No'}")
sim = TokenSimulator()
sim.simulate_session(5)
What's Next
Learn about multi-factor authentication for layered security, then explore passwordless authentication for modern login experiences.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro