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
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 callWhy 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
HttpOnlycookie, 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:
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 tokensThe handler
@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.
The refresh cookie
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:
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:
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
HttpOnlycookie “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
Refererheaders; use theAuthorizationheader. - A CORS policy that reflects any
Originwith credentials. That is what would let a cross-site page call/auth/refreshand read the new access token.SameSite=Strictkeeps 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).
Related
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.