backend / authentication / jwt / 04_access_refresh_tokens.md

Access and Refresh Tokens

8 interview angles 8 min read source

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:

  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:

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.