Backend / Authentication / JWT / 04_access_refresh_tokens.md

Access and Refresh Tokens

Updated 8 interview angles 8 min read source
On this page15
  1. The pattern
  2. Why two tokens
  3. Access token lifetime
  4. Refresh token lifetime
  5. Refresh token rotation — the modern best practice
  6. Where to store tokens
  7. The “silent refresh on app start” flow
  8. Silent refresh on access token expiry
  9. Server-side refresh endpoint
  10. Detecting refresh token reuse
  11. Revoking on logout
  12. Sliding vs absolute expiry
  13. Common pitfalls
  14. Common interview confusions
  15. Interview angle

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 Tokens: ID, Access, Refresh.

The pattern

text
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

text
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:

text
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

javascript
// On app load
async function bootstrap() {
  try {
    const response = await fetch("/api/refresh", {
      method: "POST",
      // send the refresh cookie
      credentials: "include",
    });
    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

javascript
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:

javascript
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

python
@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:

  1. Look up by rt value (DB or Redis).
  2. Verify not revoked, not expired.
  3. Issue new rt + at.
  4. Mark old rt revoked (or delete it).
  5. Set new cookie.

Detecting refresh token reuse

Track a “token family” — all refresh tokens issued to the same login session:

python
class RefreshToken:
    token: str
    user: User
    # uuid; shared across rotations
    family_id: str
    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

python
@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
python
# 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 8

  • “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.