Token Refresh Patterns: Rotating, Sliding, and Reactive Refresh
Short-lived access tokens need graceful refresh. Three common patterns, one big pitfall.
Why refresh tokens exist
Access tokens should be short-lived (15 min – 1 hour) so that a leaked token is usable for a short window. But users shouldn't have to log in every 15 minutes. Refresh tokens bridge the gap:
- Access token — short-lived, sent on every API call.
- Refresh token — long-lived, stored securely, only ever sent to
/auth/refresh.
When the access token expires, the client sends the refresh token and gets a new access token (and often a new refresh token).
The basic refresh call
/auth/refreshcurl -X POST 'https://demo.totalshiftleft.ai/auth/refresh' \
-H 'Content-Type: application/json' \
-d '{"refresh_token":"rt_demo_abc123"}'Response:
{
"access_token": "new_access_token...",
"refresh_token": "new_refresh_token...",
"expires_in": 900
}
Three patterns govern how refresh tokens behave. Pick one deliberately.
Pattern 1: Static refresh token
The refresh token stays the same across refreshes. Only the access token changes.
Pros: Simple. Client only needs to track one refresh token.
Cons: If the refresh token leaks, the attacker has persistent access until the refresh token's own expiry (often weeks or months). No way to detect theft.
Verdict: Only acceptable for low-security systems or server-to-server flows where the refresh token is stored in a vault.
Pattern 2: Rotating refresh tokens (recommended)
Every refresh issues a new refresh token and invalidates the old one. Client stores the new one; old one is dead.
Pros:
- Theft detection. If an attacker uses a stolen refresh token, the legitimate client's next refresh will fail (the old token is gone). You know the moment theft happens.
- Shorter effective window for leaked tokens.
Cons: Clients must handle token updates correctly — no race conditions between two tabs trying to refresh simultaneously.
Verdict: Default for user-facing apps. OAuth 2.1 recommends this.
Pattern 3: Sliding expiration
Each refresh extends the overall session expiry. Active users stay logged in indefinitely; inactive users get kicked out after the sliding window.
Pros: Good UX. Users in the middle of a workflow don't get logged out.
Cons: Never-expiring session for actively-used stolen tokens. Combine with absolute max-session-age (e.g., "no matter what, log out after 30 days").
Verdict: Common in consumer apps. Combine with rotation and an absolute cap.
Detecting refresh token theft
When using rotation, the theft detection logic is:
- Client A (legitimate) refreshes, gets
RT_2, discardsRT_1. - Attacker also has
RT_1somehow. Tries to use it. - Server sees
RT_1— it's in the "used" table (or just not in the valid table). - Server flags this as suspicious: revoke all of the user's refresh tokens and force re-login.
Without that "revoke all" step, the attacker just races the legitimate user.
The race condition
Two tabs opened, access token expires, both tabs try to refresh:
- Tab A:
POST /auth/refresh { refresh_token: RT_1 }→ getsRT_2. - Tab B:
POST /auth/refresh { refresh_token: RT_1 }→ fails (already used). - Tab B's user sees a logout they didn't expect.
Solutions:
- Grace window — the old refresh token works for 30 seconds after rotation. Enough for a race, not enough for theft.
- Broadcast channel — Tab A's new token is broadcast to Tab B via
BroadcastChannelorlocalStorageevent. - Mutex — a browser mutex ensures only one refresh is in flight per origin.
Typical answer: grace window + broadcast channel. Mutex alone doesn't help across page reloads.
What to test
Happy paths
- Valid refresh token → new access + refresh pair.
- New access token works on API endpoints.
- New refresh token works for the next refresh.
Security — rotation
- Old refresh token used after rotation → 401 (or 200 if within grace window, then 401 after grace window).
- Refresh token reuse triggers revoke-all (simulate by refreshing, then trying the old token, then trying the new one — new one should also fail).
- Refresh token issued for user A used by user B — 401 (user context binding).
Security — general
- Refresh token after user password change → 401.
- Refresh token after logout → 401.
- Refresh token with different
client_idthan the one it was issued to → 401. - Refresh token at
exp→ 401. - Refresh token introspected —
active: falseafter revoke.
Edge cases
- Simultaneous refresh (two tabs) — assert grace window behavior.
- Refresh when access token is not expired — should still work (client might refresh proactively).
- Missing body
{}→ 400invalid_request. - Refresh token in query string (
?refresh_token=...) — should be rejected or logged as a security warning.
Absolute limits
- Refresh 100 times in quick succession — should work (no limit on frequency, only on session age).
- Refresh past absolute max session age — 401
session_expired, user must re-login.
Storage recommendations
Browser clients:
- Access token: in-memory only. Never localStorage.
- Refresh token: httpOnly, Secure, SameSite=Strict cookie. Never JavaScript-accessible.
- The
/auth/refreshendpoint reads the cookie and returns a new access token in the response body.
Mobile clients:
- Access token: in-memory.
- Refresh token: OS keychain (Keychain on iOS, Keystore on Android). Never in app preferences.
Backend clients (CLI tools, scripts):
- Access token: in-memory.
- Refresh token: OS-specific secret store (macOS Keychain, Windows Credential Manager, Linux Secret Service).
Common bugs refresh tests catch
1. No revocation on password change. User changes password because they suspect compromise; old refresh tokens keep working.
2. No client_id binding. Refresh token issued for Mobile App works on Web App — any secret leak cascades.
3. Grace window too long. "30 minutes of leeway" is a 30-minute window for an attacker.
4. Refresh token rotation doesn't mark old as invalid. Rotation without invalidation is not rotation — it's just issuing extra tokens.
5. 500 on expired refresh token. Should be 401 with a clear code so clients log the user out gracefully instead of showing an error page.
What's next
Auth cluster done. Next cluster: testing patterns — validation, retries, negative testing, and contract testing.
Related lessons
JWTs pack auth and user context into a signed string. Simple on the surface, full of traps underneath.
OAuth 2.0's machine-to-machine flow. Clean, standard, and easy to test once you see the shape.
Simple, common, and surprisingly easy to get wrong. Here's the API key playbook.
Read more on the blog
Master OAuth API testing with best practices for authorization code, client credentials, and token flows. Complete 2026 guide for secure OAuth implementation.
Complete guide to JWT authentication testing. Learn to validate token signatures, claims, expiration, and common JWT vulnerabilities in API security.