Access and Refresh Tokens
The two-token pattern: short-lived access tokens for API calls, longer-lived refresh tokens for getting new access tokens without re-prompting the user. Standard in OAuth 2.0 and OIDC; widely adopted beyond.
For ID tokens (OIDC-specific, identity-vs-authorization distinction) see ../sso/03_tokens_id_access_refresh.md.
The pattern
1. Login: POST /login { username, password }
2. Server returns: { access_token: "eyJ...", refresh_token: "rt_xyz", expires_in: 900 }
3. Use access: every API call: Authorization: Bearer eyJ...
4. Access expires: 401 from API after 15 min
5. Refresh: POST /token { grant_type: "refresh_token", refresh_token: "rt_xyz" }
6. Server returns: new access_token (and new refresh_token, if rotating)
7. Retry the API call with the new access token
Two tokens, two lifetimes, two purposes. Short access = limited exposure; longer refresh = no user re-prompt.
Why two tokens
Why not just one long-lived token? Trade-offs:
| Single long-lived token | Two-token pattern |
|---|---|
| Stolen token works forever | Stolen access token works for minutes |
| Logout requires revocation infrastructure | Logout = revoke refresh; access expires naturally |
| Can’t add granular control | Refresh can be revoked while access tokens drain |
| Simpler client code | More moving parts |
The two-token pattern trades complexity for safer failure modes. A leaked access token is a 15-minute problem; a leaked refresh token can be revoked.
Access token lifetime
Typical: 5-60 minutes. Shorter is safer but more refresh round trips.
| Lifetime | Use case |
|---|---|
| 5 min | banking, admin consoles, high-stakes ops |
| 15 min | typical SaaS default |
| 60 min | low-risk consumer apps |
| > 1 hr | rare; defeats the point |
The choice trades user experience (more refreshes if you do interactive work) for security (shorter window of exposure if leaked).
Refresh token lifetime
Typical: hours to weeks. Longer = “remember me” UX; shorter = more frequent re-login.
| Lifetime | Use case |
|---|---|
| 8 hours | bank apps; “session” lifetime |
| 24 hours | typical web apps |
| 7-30 days | mobile apps |
| 90+ days | “remember me forever” |
Pair long refresh lifetimes with rotation (below).
Refresh token rotation — the modern best practice
Time 0: Client has RT_1, AT_1 (expires in 15 min)
Time 15: Client refreshes:
POST /token { refresh_token: RT_1 }
Server returns: { access_token: AT_2, refresh_token: RT_2 }
RT_1 is now invalid; the family advances to RT_2
Time 30: Client refreshes again with RT_2 → gets RT_3
Each refresh issues a NEW refresh token and invalidates the previous one. Token “family” advances.
Why this matters: if an attacker steals RT_1 and uses it AFTER the legitimate client has rotated to RT_2:
Client → /token with RT_2 → success (RT_3 issued)
Attacker → /token with RT_1 → DETECTED REUSE
The server sees RT_1 used after it was rotated. This is the reuse detection. Response:
- Invalidate the entire token family (RT_1, RT_2, RT_3 all become invalid).
- Force re-authentication.
- Optionally alert the user.
Without rotation: a stolen RT works until expiry, days/weeks of compromise. With rotation: stolen RT is detected on first use of the rotated copy.
Implemented in: Auth0 (default), Okta, modern OAuth servers, simplejwt-rotation.
Where to store tokens
The single most-asked question on JWT auth in interviews.
| Storage | Access Token | Refresh Token |
|---|---|---|
localStorage |
XSS-vulnerable | NEVER — full session compromise |
sessionStorage |
XSS-vulnerable, tab-scoped | NEVER |
| In-memory JS variable | XSS-safe, lost on reload | (can’t survive reload) |
| HttpOnly Secure cookie | XSS-safe, sent on every request automatically | XSS-safe |
| Mobile secure storage (Keychain/Keystore) | best for native | best for native |
The current best practice for SPAs:
- Refresh token in HttpOnly Secure SameSite=Strict cookie. JS can’t read it; XSS-safe.
- Access token in memory. Lost on reload, but a silent refresh on app start gets a new one via the cookie.
This pattern (sometimes called BFF — Backend For Frontend — pattern) avoids the SPA ever touching the refresh token directly.
The “silent refresh on app start” flow
// On app load
async function bootstrap() {
try {
const response = await fetch("/api/refresh", {
method: "POST",
credentials: "include", // send the refresh cookie
});
const { access_token } = await response.json();
setAccessTokenInMemory(access_token);
} catch {
// No valid refresh cookie; user must log in
redirectToLogin();
}
}
If the refresh cookie is valid, user is auto-logged-in. Otherwise login flow.
Silent refresh on access token expiry
async function apiCall(url, options) {
let response = await fetch(url, {
...options,
headers: { Authorization: `Bearer ${accessToken}` },
});
if (response.status === 401) {
// Token expired; refresh
accessToken = await refreshAccessToken();
response = await fetch(url, {
...options,
headers: { Authorization: `Bearer ${accessToken}` },
});
}
return response;
}
Wrap fetch in a function that retries on 401. Combine with a single in-flight refresh promise so concurrent requests share one refresh:
let refreshPromise = null;
function refreshAccessToken() {
if (!refreshPromise) {
refreshPromise = fetch("/api/refresh", { method: "POST", credentials: "include" })
.then(r => r.json())
.then(({ access_token }) => access_token)
.finally(() => { refreshPromise = null; });
}
return refreshPromise;
}
Otherwise 10 concurrent requests = 10 simultaneous refreshes, all racing.
Server-side refresh endpoint
@app.post("/token")
def refresh(request):
rt = request.cookies.get("refresh_token")
if not rt:
raise HTTPException(401, "Missing refresh token")
try:
token_record = RefreshToken.objects.select_for_update().get(token=rt, revoked=False)
except RefreshToken.DoesNotExist:
raise HTTPException(401, "Invalid refresh token")
if token_record.expires_at < now():
raise HTTPException(401, "Refresh expired")
# Rotation: invalidate this one, issue new
token_record.revoked = True
token_record.save()
new_rt = create_refresh_token(user=token_record.user, family_id=token_record.family_id)
new_at = create_access_token(user=token_record.user)
response = JSONResponse({"access_token": new_at})
response.set_cookie("refresh_token", new_rt.token, httponly=True, secure=True, samesite="strict")
return response
Each refresh:
- Look up by
rtvalue (DB or Redis). - Verify not revoked, not expired.
- Issue new
rt+at. - Mark old
rtrevoked (or delete it). - Set new cookie.
Detecting refresh token reuse
Track a “token family” — all refresh tokens issued to the same login session:
class RefreshToken:
token: str
user: User
family_id: str # uuid; shared across rotations
revoked: bool
expires_at: datetime
def refresh(rt):
record = RefreshToken.objects.get(token=rt)
if record.revoked:
# Reuse detected. Revoke the whole family.
RefreshToken.objects.filter(family_id=record.family_id).update(revoked=True)
raise HTTPException(401, "Token reuse detected. Please re-authenticate.")
# ... normal rotation flow
If a revoked refresh is presented, someone has a stolen copy. Burn the whole family to be safe.
Revoking on logout
@app.post("/logout")
def logout(request, response):
rt = request.cookies.get("refresh_token")
if rt:
RefreshToken.objects.filter(token=rt).update(revoked=True)
# Or: revoke entire family
response.delete_cookie("refresh_token")
return {"message": "Logged out"}
The access token is still valid until exp (no server-side check unless you maintain an access-token blocklist — usually skip for short tokens). The refresh token is revoked, so no new access tokens can be issued.
For “log out everywhere”: revoke ALL refresh tokens for this user.
Sliding vs absolute expiry
| Policy | What |
|---|---|
| Absolute | refresh token expires N hours after issuance, no matter what |
| Sliding | each refresh extends expiry; absolute cap as safety net |
# Sliding with absolute cap
def refresh(rt):
record = ...
if record.created_at + ABSOLUTE_MAX < now():
raise HTTPException(401, "Re-login required")
# Issue new refresh; reset expiry
new_rt = create_refresh_token(expires_in=SLIDING_PERIOD)
...
UX: sliding feels seamless (“never logged out as long as you’re active”). Security: absolute cap forces eventual re-login.
Bank apps: short absolute (8 hours). Consumer apps: 30-day absolute + 14-day sliding (idle 14 days = logged out; active = up to 30 days).
Common pitfalls
- Refresh token in localStorage — XSS reads it; permanent session compromise.
- No rotation — leaked RT works for days/weeks.
- No reuse detection — rotation alone catches passive theft only on next refresh; reuse detection catches active concurrent use.
- One refresh endpoint without rate limit — DoS via refresh spam.
- Access token in HTTP referrer — leaked to third parties. Use Authorization header, not URLs.
- Refresh cookie without
SameSite=Strict— CSRF can trigger refresh on attacker’s behalf. - Long-lived access tokens — defeats the entire pattern.
Common interview confusions
- “The access token is for accessing the app.” — sort of. It’s for authorizing API calls; the API checks its claims. The user is authenticated client-side via the session/refresh.
- “Refresh tokens are JWTs.” — sometimes yes, sometimes opaque random strings. Opaque is simpler for server-side revocation; JWT refresh tokens are rare and add complexity.
- “You should never refresh from the frontend.” — you can; just store the refresh token in an HttpOnly cookie so frontend code can’t read it, only send it. The cookie-protected refresh endpoint does the work.
Interview angle
- “Why two tokens — access + refresh?” — short-lived access (5-60 min) limits exposure of leaked credentials. Refresh (longer, e.g. days) provides UX continuity without long-lived API tokens. Revocation = revoke refresh; access tokens expire naturally.
- “What’s refresh token rotation?” — each refresh issues a new refresh token; the previous one is invalidated. Detects token theft: when both the original and rotated copies are used, the server sees reuse → revoke entire family → force re-login.
- “Where do you store refresh tokens in a browser?” — HttpOnly Secure SameSite=Strict cookie. JS can’t read it (XSS-safe); browser sends it automatically on requests to your origin. Never localStorage.
- “How long should access vs refresh tokens live?” — access: 5-60 min depending on risk. Refresh: hours (banks, sensitive) to weeks (consumer apps with rotation).
- “How does silent refresh work?” — on access-token expiry (or app start), frontend POSTs to /refresh with the cookie; server validates the refresh token, issues new access + refresh, sets new cookie. Frontend retries the failed request.
- “What happens if a refresh token is stolen?” — with rotation: detected on next use after legitimate rotation; entire family revoked; user re-authenticates. Without rotation: works until expiry — potentially days of compromise.
- “Sliding vs absolute expiration?” — sliding extends expiry on each refresh (feels seamless to active users). Absolute is a hard cap regardless of activity (forces periodic re-login). Combine: sliding within an absolute cap.
- “How do you log out a user with refresh-token auth?” — revoke the refresh token (mark deleted in DB / Redis). Access token still valid until
exp(usually short enough to ignore). For “logout everywhere,” revoke all refresh tokens for that user.