Backend / Authentication / JWT / 04_access_refresh_tokens.md

Access and Refresh Tokens

Updated 6 interview angles 12 min read source
On this page12
  1. The pattern
  2. Why two tokens
  3. Where each one lives and is checked
  4. Lifetimes, and what they cost
  5. Storage in the browser, stated correctly
  6. The refresh endpoint
  7. Silent refresh on the client
  8. Native apps
  9. Where it breaks in production
  10. The trade not taken: no tokens in the browser at all
  11. Related
  12. Interview angle

Access and Refresh Tokens

Article.

Two tokens with two jobs. The access token is short-lived and goes with every API call; each resource server validates it locally, without a lookup. The refresh token is long-lived, goes only to the token endpoint, and is checked against server-side state every time it is used. This note is the server-side reference for the pair.

ID tokens are in Tokens: ID, Access, Refresh, and browser storage in depth in Token Storage — JWT in localStorage vs httpOnly Cookie.

The pattern

text
1. Login (or an OAuth code exchange) returns:
     access_token (15 min), refresh_token (days)
2. Every API call:  Authorization: Bearer <access_token>
3. Access expires:  the API answers 401
4. Refresh:         POST /auth/refresh with the refresh token
5. Server returns:  a new access_token and, rotating,
                    a new refresh_token; the old one is dead
6. The client retries the failed call

Why two tokens

One long-lived token Access + refresh
If stolen works for its whole life the access token works for minutes
Logout needs a check on every request revoke the refresh token
Account changes need a lookup per call take effect at the next refresh
Client code simpler more moving parts

The refresh is the checkpoint. An access token is checked by signature alone, so nothing that happens after it was minted — a disabled account, a password change, a revoked session — affects it until it expires. The refresh token is looked up every time, so that is where those decisions take effect. The access TTL is the exposure window; the refresh is where the server gets a vote again.

Where each one lives and is checked

Access token Refresh token
Sent to every API call only the token endpoint
Format usually a JWT an opaque random string
Checked by each API, locally the auth server, against a store
Lifetime minutes hours to weeks
Revoked by waiting for exp deleting or marking the row
If stolen usable until exp usable until rotated or revoked

A JWT refresh token buys nothing: the server must look it up anyway to honour revocation and rotation, so a random string with a hashed row is simpler.

Lifetimes, and what they cost

Token Sensible range Defaults you will meet
Access 5-15 min; up to 60 for low risk Keycloak 5 min; Cognito, Google 60 min
Refresh 8 h (banking) to 30-90 days, rotated Cognito 30 days; Entra ID 90 days, 24 h for SPAs
ID read once at login usually matches the access token

Cognito lets access and ID tokens range from 5 minutes to 24 hours, and refresh tokens from 60 minutes to 10 years; Entra ID issues access tokens of 60 to 90 minutes. Shorter access tokens cost refreshes: the token endpoint sees concurrent sessions divided by the access TTL. At a million sessions, 5-minute tokens mean about 3,300 refreshes a second and 15-minute tokens about 1,100, each a database write when tokens rotate.

Sliding and absolute expiry

Policy What
Absolute the family dies a fixed time after login, whatever happens
Sliding each refresh extends an idle timeout, within the cap

Sliding feels seamless to active users; the absolute cap forces an eventual re-login. Consumer apps often run a 14-day idle timeout inside a 30-day cap, banks a few hours of either. For browser clients RFC 10017 makes it a rule: refresh tokens must have a maximum lifetime or expire when unused, and a rotated token must not outlive the one it replaced.

Storage in the browser, stated correctly

No browser storage makes a token XSS-safe. Where it lives only decides what an attacker running script in your page gets:

Where What an XSS gets
localStorage, sessionStorage the token itself, usable from anywhere until it expires
a JS variable in memory the token while the script runs: read it, or wrap fetch
an HttpOnly cookie not the cookie, but its use: fetch("/auth/refresh")
a BFF session cookie in-page actions through the BFF; no OAuth token exists

An in-memory token is not XSS-safe, only non-persistent. An HttpOnly cookie is unreadable but rideable: an injected script can POST to the refresh endpoint with credentials: "include" and read a brand-new access token from the JSON. What the cookie prevents is exfiltration of the refresh token itself, which is still worth a lot. Native apps use the Keychain or Keystore.

Three browser architectures

RFC 10017 (OAuth 2.0 for Browser-Based Applications, August 2026) ranks them, most secure first:

  • Backend-for-frontend (BFF). The backend is the OAuth client and keeps every token; the browser holds only a session cookie. An XSS can act through the BFF while it runs, and gets no token to take away.
  • Token-mediating backend. The backend keeps the refresh token and hands access tokens to the browser. “Refresh token in an HttpOnly cookie, access token in memory” is this pattern, not a BFF, and the RFC warns that an attacker can “request a fresh token from the token-mediating backend”.
  • Browser-only client. The SPA is a public client holding both tokens. Its refresh tokens must be rotated or sender-constrained (DPoP), with the lifetime rules above.

The refresh endpoint

Tokens are stored hashed, and each row records its family, its absolute expiry, and when it was used or revoked:

python
import hashlib
import secrets
from datetime import UTC, datetime
from typing import Annotated

from fastapi import APIRouter, Cookie, HTTPException, Response
from sqlalchemy import select, update

router = APIRouter(prefix="/auth")
RT_COOKIE = "__Secure-rt"

def digest(token: str) -> str:
    return hashlib.sha256(token.encode()).hexdigest()   # store hashes, not tokens

The handler

python
@router.post("/refresh")
async def refresh(
    response: Response, rt: Annotated[str | None, Cookie(alias=RT_COOKIE)] = None
) -> dict:
    if rt is None:
        raise HTTPException(status_code=401)
    now, new_rt, ok = datetime.now(UTC), secrets.token_urlsafe(32), False
    async with SessionLocal.begin() as db:            # one transaction, commits on exit
        row = await db.scalar(
            select(RefreshToken)
            .where(RefreshToken.hash == digest(rt))
            .with_for_update()                        # lock the row
        )
        if row is None or row.revoked_at or row.expires_at <= now:
            pass
        elif row.used_at:                             # a rotated token came back
            await db.execute(
                update(RefreshToken)
                .where(RefreshToken.family_id == row.family_id)
                .values(revoked_at=now)               # theft or a race: burn the family
            )
        elif await is_active(db, row.user_id):        # the checkpoint
            ok, user_id, expires_at = True, row.user_id, row.expires_at
            row.used_at = now                         # rotate; the cap does not move
            db.add(RefreshToken(hash=digest(new_rt), family_id=row.family_id,
                                user_id=user_id, expires_at=expires_at))
    if not ok:                                        # raised after the commit
        raise HTTPException(status_code=401)
    response.set_cookie(
        RT_COOKIE, new_rt, path="/auth/refresh", httponly=True, secure=True,
        samesite="strict", max_age=int((expires_at - now).total_seconds()),
    )
    return {"access_token": mint_access_token(user_id), "expires_in": 900}

What it gets right

Each of these is a common bug:

  • Hashes, not tokens. A leaked table of refresh-token hashes cannot be replayed; a table of tokens is a table of live sessions.
  • One transaction with a row lock. Two requests with the same token cannot both rotate it, and with_for_update() outside a transaction would hold no lock at all.
  • Reuse is detected, not just refused. Filtering the query on “not used” would turn a replayed token into a plain 401 and the theft signal would be lost. The family revocation also commits before the 401 is raised; raising inside the transaction would roll it back.
  • The absolute cap is inherited. The new token keeps the family’s expiry, so rotation never extends a session.

Rotation in depth — the two-tab race, grace windows, what reuse really signals — is in Refresh Token Rotation.

Cookie prefixes make the browser enforce the attributes. __Host- requires Secure, Path=/ and no Domain, so a sibling subdomain cannot plant a cookie of that name — but the cookie then rides along with every request to the origin.

Scoping it to Path=/auth/refresh keeps it off every other request and out of every other handler’s logs, and then only __Secure- is allowed. Untrusted subdomains argue for __Host-; otherwise the path scope is worth more.

Use SameSite=Strict and a Max-Age equal to the remaining absolute cap. On logout, delete it with the same attributes. Starlette’s delete_cookie defaults to path="/" and secure=False, which matches neither the path nor the prefix, so the browser keeps the cookie:

python
response.delete_cookie(RT_COOKIE, path="/auth/refresh", secure=True,
                       httponly=True, samesite="strict")

Silent refresh on the client

On page load there is no access token in memory, so the app calls the refresh endpoint once; a 401 there means the session is gone and the user logs in. After that, refresh about a minute before exp, and keep a retry on 401 for clock skew and revocations:

javascript
let accessToken = null;
let inflight = null;  // one refresh per tab; the lock makes it one across tabs

function refreshAccessToken() {
  inflight ??= navigator.locks
    .request("token-refresh", async () => {
      const r = await fetch("/auth/refresh", {
        method: "POST", credentials: "include",
      });
      if (!r.ok) throw new Error("login required");
      return (await r.json()).access_token;
    })
    .finally(() => { inflight = null; });
  return inflight;
}

async function api(url, options = {}) {
  const call = () => fetch(url, { ...options,
    headers: { ...options.headers, Authorization: `Bearer ${accessToken}` } });
  let r = await call();
  if (r.status === 401) {
    accessToken = await refreshAccessToken();
    r = await call();
  }
  return r;
}

One refresh, across tabs

The shared promise stops ten concurrent 401s from sending ten refreshes. The Web Lock does the same across tabs, which share the cookie but not memory: with rotation, two tabs refreshing at the same instant send the same refresh token, and the second looks exactly like theft. Under the lock the second tab waits, then sends the cookie the first one just received.

Native apps

Store the refresh token in the Keychain or Keystore with a device-only accessibility class, so it is not restored to another phone from a backup. Log in through the system browser (RFC 8252), never a WebView. For a public client RFC 9700 accepts sender-constrained refresh tokens as the alternative to rotation: with DPoP (RFC 9449) the token is useless without the app’s private key.

Where it breaks in production

  • Random logouts after turning rotation on. The two-tab race above, plus mobile clients that retry a refresh whose response was lost on a flaky network: the retry presents a token that was already rotated, and reuse detection burns the family. The symptom is a support queue of “it logs me out”, not an error rate. Serialize on the client and give the server a grace window of a few seconds for the same token (Refresh Token Rotation).
  • A refresh stampede after an outage. Every client whose access token expired during the outage refreshes at once when it ends, and with rotation each refresh is a locked row and a write. Add jitter to the client’s “refresh a minute early” timer and size the token endpoint for the burst, not the average.
  • Refresh token in localStorage. One XSS and it is exfiltrated: a session the attacker keeps after the page is closed.
  • Calling an in-memory token or an HttpOnly cookie “XSS-safe”. Neither is; see the storage table.
  • Long-lived access tokens. Every account change waits for them to expire, which defeats the pattern.
  • A refresh endpoint with no rate limit. It is a credential-checking endpoint like login.
  • Tokens in URLs. Query strings land in logs, history and Referer headers; use the Authorization header.
  • A CORS policy that reflects any Origin with credentials. That is what would let a cross-site page call /auth/refresh and read the new access token. SameSite=Strict keeps the cookie off cross-site requests anyway, and without CORS a forged POST cannot read the response.

The trade not taken: no tokens in the browser at all

For a first-party web app whose API lives on the same site, the whole pair is optional. A server-side session behind an HttpOnly cookie gives immediate revocation (delete the row), no token for an XSS to take away, and no refresh endpoint, rotation race or client refresh code. That is the BFF shape from the storage section, and RFC 10017 ranks it first for exactly that reason.

Access and refresh tokens earn their complexity when the token has to travel: to APIs on other domains or run by other teams, to mobile and desktop clients, to third parties through OAuth. Choosing them for a single-origin web app buys a stateless check on each request at the cost of everything above (JWT vs Session-Based Auth).

Interview angle 6

  • “Why two tokens - access and refresh?” — the access token is checked locally on every call, so it must be short-lived: its TTL is the exposure window. The refresh token is checked against server state on every use, so revocation, disabled accounts and password changes take effect there.
    • Follow-up: would you use them for a web app whose API is on the same site? Usually not: a server-side session cookie gives instant revocation and no token in the browser. The pair earns its cost when tokens travel to other domains, mobile clients or third parties.
  • “Why not a single 24-hour access token?” — then nothing you decide about that user bites for up to 24 hours. Short access tokens cost refreshes: concurrent sessions divided by the TTL, about 3,300 a second for a million sessions on 5-minute tokens.
  • “Access token in memory, refresh token in an HttpOnly cookie: what can an XSS still do?” — call the refresh endpoint with credentials and read a fresh access token from the JSON for as long as it runs. Only exfiltration of the refresh token is prevented. That shape is a token-mediating backend; a BFF keeps every token server-side.
  • “How long should each token live?” — access 5-15 minutes for sensitive apps and up to an hour for low-risk ones; refresh from hours to 30-90 days, rotated, with an idle timeout inside an absolute cap.
  • “Two tabs hit a 401 at the same moment with rotation on. What happens?” — both redeem the same refresh token, the second looks like reuse, and the family is revoked. Serialize refreshes across tabs with the Web Locks API, or allow a short grace window on the server.
    • Follow-up: what does that look like in production? Not errors: a rise in “it keeps logging me out” tickets after rotation ships, from tabs and from mobile retries of a refresh whose response was lost.
  • “What does the refresh endpoint have to get right?” — hashed storage, a locked row in one transaction, reuse detection that commits before it fails, the account check, and an absolute cap that rotation never extends.