Refresh tokens are long-lived credentials. An access token might expire in 15 minutes, but a refresh token that can generate new access tokens is often valid for 30 days or longer. If an attacker extracts a refresh token from a compromised device, a log file, a proxy cache, or a network interception — they have persistent access until that token expires or is explicitly revoked. Token rotation is the primary mechanism for limiting that window and detecting theft.
What refresh token rotation means
Refresh token rotation means that every time a client exchanges a refresh token for a new access token, the authorization server issues a new refresh token and invalidates the old one. The client stores the new refresh token and discards the old one. The sequence looks like this:
// Initial token exchange after login
POST /oauth/token
{
"grant_type": "authorization_code",
"code": "abc123",
"code_verifier": "..."
}
// Response
{
"access_token": "eyJ...",
"refresh_token": "rt_v1_a1b2c3d4",
"expires_in": 900
}
// 15 minutes later — access token expired, use refresh token
POST /oauth/token
{
"grant_type": "refresh_token",
"refresh_token": "rt_v1_a1b2c3d4"
}
// Response — old refresh token is now invalid
{
"access_token": "eyJ...",
"refresh_token": "rt_v1_e5f6g7h8", // new token
"expires_in": 900
}
Without rotation, a stolen refresh token remains valid indefinitely (or until explicit revocation). With rotation, the stolen token is only usable once before the legitimate client rotates it away on their next refresh cycle.
Absolute TTL vs sliding window expiry
There are two distinct TTL concepts for refresh tokens, and most implementations confuse them or collapse them into one value.
Absolute TTL is the maximum lifetime of a refresh token family — from initial issuance to forced expiry, regardless of usage. A 30-day absolute TTL means the user must re-authenticate after 30 days, period.
Sliding window TTL is the inactivity timeout. Each time the token is used, the window resets. A 7-day sliding window with a 90-day absolute TTL means: if the user opens the app every day, they never get logged out (until 90 days). If they stop using the app for 8 days, the sliding window expires and they must re-authenticate.
// Token storage schema (database row per refresh token)
interface RefreshToken {
token_hash: string; // SHA-256 of the raw token value
family_id: string; // UUID shared across all rotations of one grant
user_id: string;
client_id: string;
issued_at: Date;
family_issued_at: Date; // when the original token in this family was issued
expires_at: Date; // absolute expiry (family_issued_at + 90 days)
last_used_at: Date;
sliding_expires_at: Date; // last_used_at + 7 days
revoked: boolean;
revoked_at?: Date;
}
function isRefreshTokenValid(token: RefreshToken): boolean {
const now = new Date();
if (token.revoked) return false;
if (now > token.expires_at) return false; // absolute TTL
if (now > token.sliding_expires_at) return false; // inactivity TTL
return true;
}
The combination of both TTLs gives you a reasonable security posture: active users stay logged in, inactive users are periodically forced to re-authenticate, and there is a hard ceiling on how long any credential chain can last.
Reuse detection and theft signaling
The most powerful property of token rotation is theft detection via reuse. Here is the scenario: an attacker steals a refresh token. The legitimate client is still active and uses the refresh token before the attacker does, issuing a new token and invalidating the old one. Now the attacker tries to use their stolen (now-invalidated) token. Under naive rotation, this just returns an error. Under rotation with reuse detection, presenting an already-rotated token is a strong signal that the token family has been compromised.
The correct response to reuse detection is to revoke the entire token family — not just the presented token, but all active tokens derived from the same original grant. This logs both the attacker and the legitimate user out, forcing re-authentication. The legitimate user loses their session, but so does the attacker, and the user can re-authenticate while the attacker cannot.
async function handleRefreshTokenRequest(rawToken: string) {
const tokenHash = sha256(rawToken);
const stored = await db.refreshTokens.findByHash(tokenHash);
if (!stored) {
// Token doesn't exist — either expired and cleaned up, or fabricated
throw new InvalidTokenError();
}
if (stored.revoked) {
// REUSE DETECTED — this token was already rotated
// Revoke the entire family to protect the legitimate user
await db.refreshTokens.revokeFamily(stored.family_id);
await notifyUser(stored.user_id, 'suspicious_activity');
throw new TokenReuseDetectedError();
}
if (!isRefreshTokenValid(stored)) {
throw new TokenExpiredError();
}
// Token is valid — rotate it
const newRawToken = generateSecureToken();
const newTokenHash = sha256(newRawToken);
const now = new Date();
await db.transaction(async (tx) => {
// Invalidate old token
await tx.refreshTokens.update(stored.id, {
revoked: true,
revoked_at: now
});
// Issue new token in same family
await tx.refreshTokens.insert({
token_hash: newTokenHash,
family_id: stored.family_id,
user_id: stored.user_id,
client_id: stored.client_id,
issued_at: now,
family_issued_at: stored.family_issued_at,
expires_at: stored.expires_at, // absolute TTL preserved
last_used_at: now,
sliding_expires_at: new Date(now.getTime() + 7 * 24 * 3600 * 1000),
revoked: false
});
});
const accessToken = issueAccessToken(stored.user_id, stored.client_id);
return { access_token: accessToken, refresh_token: newRawToken };
}
Token families and the revocation cascade
A token family is the chain of refresh tokens issued from a single authorization grant. When a user logs in once, that creates one family. Every subsequent rotation stays in the same family. If a user logs in on their phone and their laptop, those are two separate families, and revoking one does not affect the other.
Grouping by family lets you implement targeted revocation. The user revokes "my phone session" — you revoke that family. A suspicious reuse event triggers — you revoke only the affected family, not all sessions for that user. The user clicks "log out all devices" — you revoke all families for that user.
// Revocation operations async function revokeSession(familyId: string): Promise{ await db.refreshTokens.updateMany( { family_id: familyId }, { revoked: true, revoked_at: new Date() } ); } async function revokeAllUserSessions(userId: string): Promise { await db.refreshTokens.updateMany( { user_id: userId }, { revoked: true, revoked_at: new Date() } ); } async function revokeUserSessionsForClient( userId: string, clientId: string ): Promise { await db.refreshTokens.updateMany( { user_id: userId, client_id: clientId }, { revoked: true, revoked_at: new Date() } ); }
Clock skew and race conditions
In production, mobile apps and SPAs sometimes send concurrent refresh requests — this can happen when the app is in the background and resumes, or when multiple browser tabs try to refresh simultaneously. Under strict single-use semantics, the second request arrives with an already-rotated token and triggers reuse detection, logging the user out.
A common mitigation is a brief reuse window: instead of immediately invalidating the old token, mark it as "pending rotation" and allow it to be used one more time within a 30-second window. After 30 seconds, it is fully revoked. This handles the concurrent request case without creating a meaningful attack surface — a 30-second reuse window does not help an attacker who obtained a token hours or days ago.
// Grace period for concurrent refresh requests
const ROTATION_GRACE_SECONDS = 30;
if (stored.revoked && stored.revoked_at) {
const secondsSinceRevocation =
(Date.now() - stored.revoked_at.getTime()) / 1000;
if (secondsSinceRevocation <= ROTATION_GRACE_SECONDS) {
// Within grace period — find and return the successor token
// (the token that replaced this one)
const successor = await db.refreshTokens.findSuccessor(
stored.family_id,
stored.id
);
if (successor && !successor.revoked) {
// Return the already-issued successor tokens
return { access_token: ..., refresh_token: rawSuccessor };
}
}
// Outside grace period — genuine reuse, revoke family
await db.refreshTokens.revokeFamily(stored.family_id);
throw new TokenReuseDetectedError();
}
Sizing your TTLs
There is no universal correct value — TTLs are a risk tradeoff between user experience and security exposure. Some practical reference points:
- Consumer mobile apps: 90-day absolute TTL, 30-day sliding. Aggressive enough for security, infrequent enough that users are not constantly re-authenticating.
- Financial applications: 24-hour absolute TTL, 4-hour sliding. High-value target, users expect to re-authenticate regularly.
- Enterprise SaaS with SSO: 8-hour absolute TTL matching the work day. Sessions expire overnight, users re-authenticate each morning via their identity provider.
- Developer API keys: no sliding window, 1-year absolute TTL with explicit rotation workflow and notification 30 days before expiry.
Whatever values you choose, make them configurable per client and document them in your token endpoint metadata. Clients need to know when to expect expiry so they can handle token renewal gracefully rather than presenting expired tokens to your API.