Authentication and token storage
How a web app proves who the user is on every request after login: sessions or tokens, where the proof lives in the browser, how it is refreshed and revoked, and how OAuth with PKCE and OIDC fit in.
The problem: proving who you are on every request
Picture a small fintech start-up. Its web app is a single-page app (SPA): one JavaScript bundle that talks to a JSON API. At login the API returns a signed token valid for 30 days, and the app saves it in localStorage so the user stays logged in after a reload. Every API call sends it in an Authorization: Bearer ... header.
Six months later a third-party chat widget on the help page is compromised. Its script now reads localStorage and posts the token to the attacker's server. Within an hour the attacker has the tokens of 4,000 users, each working from any computer for up to 30 days.
The team asks the obvious question: how do we log those users out? The answer hurts: the API only checks the token's signature and expiry, and keeps no list of who is logged in. A user who changes their password stays logged in on the attacker's laptop. The only quick fix is to rotate the signing key, which logs out every user at once.
Nothing in that story is exotic: it is the default result of three small choices: a long-lived token, stored where any script can read it, checked in a way that cannot be undone. This lesson is about making those three choices on purpose.
Why we need tokens at all
HTTP is stateless: each request stands alone. The user typed a password once, at login, so every later request needs some other proof that it comes from the same person.
That proof is a credential the server gives the browser after login, which the browser sends back on each request. It comes in two shapes:
- A session id. A long random string, like
s_8f2a91c4..., that means nothing by itself. The server checks it by looking it up in a table that maps it to the user. - A self-contained token. Usually a JWT (JSON Web Token, said "jot"): a small JSON document with the user id, an expiry time, and a signature. To check it, the server verifies the signature; no lookup is needed.
Two words people mix up
Authentication (authn) answers "who are you?". Login, passwords, passkeys, and the session or token that follows are all authentication. Authorization (authz) answers "what are you allowed to do?": can this user read invoice 42, can they delete a project. This lesson is about the first, and about carrying its result safely from request to request.
The two meet in the token: an access token often carries scopes or roles that the API uses for authorization. But the token only says what the server believed when it was issued, so after a role change, a token issued an hour ago still claims the old role until it expires. That gap is the root of most revocation problems later on.
The questions this lesson answers
- Should the server remember sessions, or should the token carry everything?
- Where should the credential live, so that script injection (XSS) or a forged cross-site request (CSRF) gets the attacker as little as possible?
- How do short access tokens and long refresh tokens work together, and how does rotation catch a stolen one?
- How does "Sign in with Google" work for an SPA, and why is PKCE required?
- How do you log a user out everywhere, at once?
Check your understanding · Exercise 1
In the fintech story, which choices made the leak so damaging? Select all that apply.
The mental model: a cloakroom ticket or a signed pass
Think of two ways a concert venue could let you back in after you step outside. In the first, the cloakroom gives you a numbered ticket, say #417, that means nothing on its own: the attendant looks it up in a book to find your coat. If you report the ticket stolen, they cross out line 417 and the ticket is dead.
In the second, the door staff give you a pass that says "Asha, VIP, valid until 23:00", signed with a stamp nobody else has. Any guard can check the stamp and let you in without calling anyone. But a stolen pass still works at every door until 23:00, because no guard keeps a list.
The first is a server-side session: the browser holds an opaque id, and the truth lives in a server store. The second is a self-contained token such as a JWT: the truth travels with the token, and the server only checks the signature. Almost every trade-off in this lesson comes from that one difference.
exp.What each check costs
A session check is a lookup: a read from Redis or a database by key. With Redis in the same data centre that is usually well under a millisecond, but it is a network call on every request, and the store must be up for anyone to be logged in.
A JWT check is a signature verification: with an RS256 (RSA) or ES256 (elliptic curve) key, tens of microseconds of CPU and no network. Any service with the public key can do it, which is why JWTs became popular with microservices.
Where the truth lives decides what you can change
- Revoke one login. Session: delete one row. JWT: you can't, unless you add a deny-list, which is a lookup again.
- Change a user's role. Session: update the row, and the next request sees it. JWT: old tokens keep the old role until they expire.
- See who is logged in. Session: list the user's rows, as a "your devices" page does. JWT: nothing to list.
- Scale to many services. Session: every service needs the store, or a gateway does the lookup once. JWT: every service checks locally.
In practice most large products use both. The browser holds a session (or a refresh token, which is a session by another name), and short-lived JWT access tokens are minted from it for the APIs behind. You get easy revocation at the edge, where the user is, and cheap checks inside, where the traffic is.
Check your understanding · Exercise 2
Your team uses 24-hour JWTs with no server-side store. Which requests can you serve without adding a lookup? Pick one.
Sessions and JWTs, step by step
Both designs start the same way: the user sends a password (or a passkey assertion) to POST /login, and the server checks it. They differ in what comes back.
A classic session
- The server makes a random id with at least 128 bits of randomness, for example 32 bytes from a secure random generator, encoded as base64url.
- It stores
sess:9fK2... -> { userId: 81, created: 1760851200, lastSeen: 1760851200 }in Redis with an expiry. - It replies with
Set-Cookie: __Host-sid=9fK2...; Path=/; Secure; HttpOnly; SameSite=Lax. - From then on the browser attaches
Cookie: __Host-sid=9fK2...to every request to that site. The server looks up the id, finds user 81, and updateslastSeen.
Two details matter. First, make a new session id at login and throw the old one away. If you keep an id from before login, an attacker who planted it in the victim's browser (session fixation) is now logged in as the victim. Second, set two timeouts: an idle timeout (for example 30 minutes without a request) and an absolute one (for example 30 days since login).
What a JWT really is
A JWT is three base64url strings joined by dots: a header, a payload, and a signature. The header says which algorithm signed it. The payload holds claims, small facts about the user and the token. The signature covers the first two parts, so changing one character of the payload makes the signature fail.
alg.iss(issuer): who made the token, likehttps://auth.example.com. Check it matches exactly.sub(subject): the user id. Use a stable id, not an email address that can change.aud(audience): who the token is for, likehttps://api.example.com. An API must reject tokens meant for someone else.expandiat: expiry and issued-at, in seconds since 1970. Allow a small clock skew, around 30 to 60 seconds.jti: a unique id for this token. You need it if you ever want to deny-list one token.
The mistakes that keep showing up
- Trusting the header's algorithm. Old libraries accepted
alg: none(no signature), or let an attacker switch RS256 to HS256 and sign with the public key as if it were a shared secret. Always pin the expected algorithm in the verifier. - Decoding instead of verifying.
jwt.decode()in many libraries only reads the payload; only a verify call checks the signature. - Skipping
aud. If two of your APIs trust the same issuer, a token for the low-risk one works on the high-risk one. - Making them huge. Every claim rides on every request: a 2 KB token on 40 requests is 80 KB of upload, and browsers drop cookies over about 4 KB.
- Long lifetimes. A JWT can't be called back, so its lifetime is your worst-case exposure. Keep access tokens to minutes.
Decision What should the browser hold after login?
What we pick: For a web app you own end to end, start with a server-side session in a cookie; it is simple and revocable. When you have many internal services, add short JWTs behind the edge or a BFF. Avoid long-lived JWTs held by browser JavaScript.
Check your understanding · Exercise 3
An API receives a JWT. Which checks must it do before trusting the claims? Select all that apply.
Where the credential lives in the browser
The browser has to keep the credential somewhere between requests. There are four common places, and each fails differently against the two attacks that matter most:
- XSS (cross-site scripting): the attacker gets their JavaScript to run inside your page. It can read anything your scripts can read and send any request your page can send. See the XSS lesson.
- CSRF (cross-site request forgery): a page on another site makes the user's browser send a request to your site. The browser adds your cookies on its own. The attacker can't read the answer, but the action happens. See the CSRF lesson.
The key fact: CSRF only works with credentials the browser attaches by itself, which means cookies. XSS works against everything, but how much it gets depends on whether the script can read the credential (and carry it away) or only use it while the tab is open.
The four places
localStorage: survives reloads and restarts, shared by all tabs of the origin, and readable by any script on the origin in one line. Your code adds it to anAuthorizationheader by hand.sessionStorage: likelocalStoragebut per tab, and gone when the tab closes. Just as readable by an injected script.- Memory: a variable inside your auth module. Gone on reload, so the app needs a new token at startup (usually a refresh call). A storage read can't reach it, but an injected script can wrap
window.fetchand see the header on the next call. HttpOnlycookie: set by the server, sent by the browser on matching requests, and invisible todocument.cookieand every other script API.
SameSite mostly closes.The cookie attributes that matter
A cookie is only as safe as its attributes. For a session or refresh cookie, a good default line looks like this:
Set-Cookie: __Host-sid=9fK2xQ...; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=2592000
HttpOnly: scripts can't read it, the single biggest win against token theft. A cookie set from JavaScript can never beHttpOnly.Secure: only sent over HTTPS. Don't drop it "for now" on anhttp://staging site.SameSite=Lax: not sent on cross-site subrequests or cross-sitePOST, but sent when the user follows a link to your site, so they arrive logged in.Strictalso drops it on those links, which suits a BFF behind an SPA (the BFF lesson explains when);Nonesends it everywhere and needsSecure. Chrome treats a cookie with noSameSiteasLax, but other browsers differ, so always set it.__Host-prefix: the browser only accepts the cookie if it isSecure, hasPath=/, and has noDomain, so a sibling subdomain can't overwrite it.Max-AgeorExpires: without one, it is a session cookie that dies with the browser (though many browsers restore those on restart).
Lab: attack each storage choice
Pick where the app keeps its credential, launch an attack, and see what the attacker walks away with. Watch the third choice: a refresh cookie helps less than people expect if the refresh call returns a new access token to JavaScript.
One line, localStorage.getItem("token"), and one fetch to the attacker's server. The token now works from any computer until it expires, long after the user closes the tab, and you can't revoke it if it's a plain JWT.
The lab shows the trade: header tokens (storage or memory) are immune to CSRF and exposed to XSS theft; cookies are immune to theft and exposed to CSRF. Since SameSite plus an Origin check closes CSRF cheaply, and nothing closes XSS completely, the industry default for browsers has moved to HttpOnly cookies.
Check your understanding · Exercise 4
An app keeps a 15-minute access token in memory and a refresh token in an HttpOnly; SameSite=Strict cookie scoped to /auth. The /auth/refresh call returns a new access token as JSON. An XSS script runs in the page. What can it get? Select all that apply.
Access tokens, refresh tokens, and rotation
Token design has a tension. Short-lived tokens limit the damage of theft, but a token that expires every 10 minutes would make the user log in every 10 minutes. The standard answer splits the job between two tokens.
- Access token: sent to the API on every request. Short-lived, often 5 to 15 minutes. Usually a JWT, so APIs check it without a lookup.
- Refresh token: sent only to the auth server, only to get a new access token. Long-lived, days to weeks. Opaque and stored server-side, so it can be revoked at once.
The pair gives you the best of both: the high-traffic path (API calls) is a cheap signature check, and the rare path (a refresh, a few times an hour) is a lookup where revocation takes effect. Logging a user out becomes "delete the refresh token"; their access token dies on its own within minutes.
The life of a refresh token
Each refresh token is a small state machine on the server. It starts active; when used, it is marked used and a new one takes its place; it can also expire or be revoked. The edge that matters is the one from used to revoked: a used token should never come back.
Rotation and reuse detection
With refresh token rotation, every refresh returns a new refresh token and retires the old one. All the tokens descended from one login form a family, and the server stores, for each token, a hash of it, its family id, and whether it was used. The OAuth security best practice, RFC 9700, requires rotation (or binding the token to a key the client holds) for refresh tokens held by browser apps.
Now suppose a thief copies refresh token R1, and the user's app refreshes first, getting R2. When the thief later sends R1, the server sees a used token come back. It can't tell the thief from the user, so it revokes the whole family: the thief is out, and the user logs in again. If the thief refreshes first, the same happens when the real app sends R1.
async function refresh(presented: string) {
const row = await db.refreshTokens.findByHash(sha256(presented));
if (!row || row.revokedAt || row.expiresAt < now()) throw unauthorized();
if (row.usedAt) {
// A retired token came back: someone has a copy.
await db.refreshTokens.revokeFamily(row.familyId);
audit("refresh_reuse", row.userId);
throw unauthorized();
}
await db.refreshTokens.markUsed(row.id);
const next = randomToken(32);
await db.refreshTokens.insert({ hash: sha256(next), familyId: row.familyId, userId: row.userId,
familyExpiresAt: row.familyExpiresAt, expiresAt: min(row.familyExpiresAt, now() + 7 * DAY) });
return { accessToken: signJwt(row.userId, { expiresIn: 600 }), refreshToken: next };
}
Choosing the lifetimes
| Lifetime | Typical value | What it bounds |
|---|---|---|
| Access token | 5 to 15 minutes | How long a stolen access token works, and how long a revoked user keeps API access |
| Refresh token idle | 1 to 14 days | How long a device can sit unused and still stay logged in |
| Refresh family absolute | 30 to 90 days | How long one login can last, however active, before a real sign-in again |
| Reuse grace window | 0 to about 30 seconds | How long a just-used token is still accepted, to survive network retries |
The grace window handles lost responses. If the server rotates R1 but the response is lost on a flaky connection, the app retries with R1, and strict reuse detection would log the user out. Many providers accept a just-rotated token again for a few seconds without treating it as reuse (Auth0 calls this a reuse interval, Okta a grace period). Keep it short: it is also a window for a thief.
Check your understanding · Exercise 5
The server receives refresh token R4, which was already used 2 minutes ago. What should it do? Select all that apply.
Silent refresh, lost cookies, and tab races
Refreshing sounds simple: get a new access token before the old one expires. In a real browser, two things make it hard: the old trick for doing it quietly stopped working, and five open tabs can make five refreshes at once.
How silent refresh used to work
Before refresh tokens were considered safe in browsers, SPAs refreshed with a hidden iframe that loaded the provider's /authorize?prompt=none page. The provider saw its own session cookie, skipped the login screen, and redirected back with a new token, which the frame passed to the app with postMessage.
That only works if the provider's cookie is sent inside an iframe on another site: a third-party cookie, which browsers restrict to stop tracking:
- Safari blocks third-party cookies by default since 2020, as part of Intelligent Tracking Prevention (ITP).
- Firefox gives each top-level site its own separate cookie jar by default since 2022 (Total Cookie Protection). The provider's cookie inside your iframe is a fresh, empty jar.
- Chrome still allows them by default. It dropped its phase-out plan in 2024 and the planned user-choice prompt in 2025, but blocks them in Incognito, and users can turn them off. Plans keep changing, so check the current status.
The result: an iframe-based app logs Safari users out every time the access token expires, and support fills with "I keep getting logged out on my iPhone" while the server logs show nothing.
The fixes all remove the need for a third-party cookie: refresh tokens with rotation from the app itself, a login provider on your own site (auth.example.com next to app.example.com) so its cookie is same-site, or a BFF that talks to the provider server to server. Partitioned cookies (CHIPS) don't help, because they give the iframe a separate jar per top-level site, which doesn't hold the user's login session.
The multi-tab refresh race
Rotation brings its own bug. A user comes back from lunch to three open tabs of the app, and all three call /auth/refresh within a few milliseconds with the same refresh cookie R1. The first call rotates R1 to R2; the second and third arrive with a used R1. Reuse detection revokes the family, and the user is logged out for doing nothing wrong.
The same race happens inside one tab when eight API calls all get 401 and each starts its own refresh. The fix has three layers, and you want all three:
- One refresh per tab. Keep the refresh promise in a variable; every caller awaits the same promise, and only the first creates it. This is request deduplication applied to auth.
- One refresh per browser. Take a cross-tab lock with the Web Locks API before refreshing. When the second tab gets the lock, the shared cookie jar already holds R2, so it sends R2. Or have the first tab share the new access token over
BroadcastChannel; the cross-tab sync lesson covers both. - A short grace window on the server, for cases the client can't see, such as a retry after a lost response.
let inFlight: Promise<string> | null = null;
export function getFreshToken(): Promise<string> {
inFlight ??= navigator.locks
.request("auth-refresh", async () => {
const res = await fetch("/auth/refresh", { method: "POST", credentials: "include" });
if (!res.ok) throw new Error("logged-out");
const { accessToken } = await res.json();
return accessToken as string;
})
.finally(() => { inFlight = null; });
return inFlight;
}
Refresh before it expires, not after
Waiting for a 401 costs a failed request, a refresh, and a retry on every expiry. Most clients refresh early instead, when the token has about a minute left. Timers alone are not enough, because a laptop that sleeps for an hour wakes with a dead token, so also check exp before each request. Keep the 401 path as the safety net for clock skew and revoked tokens.
Check your understanding · Exercise 6
Users report random logouts that happen most often right after they reopen a laptop with several tabs of your app open. Rotation with strict reuse detection is on. What are good fixes? Select all that apply.
OAuth 2.0, OpenID Connect, and PKCE
So far the app checked the password itself. Many apps instead hand login to someone else: "Sign in with Google", a company's Okta or Microsoft Entra ID, or a central auth server shared by several apps. The protocols for that are OAuth 2.0 and OpenID Connect, and for browser apps they come with one required extra: PKCE.
Two protocols, two tokens
OAuth 2.0 is about access: it lets an app get an access token to call an API on the user's behalf, without ever seeing the user's password. OpenID Connect (OIDC) is a thin layer on top of OAuth that adds identity: it returns an ID token, a JWT that tells the app who logged in. Four roles take part:
- Resource owner: the user.
- Client: your app, which wants tokens. A browser SPA is a public client: it can't keep a secret, because anyone can read its JavaScript.
- Authorization server: the login provider, which shows the login page and issues tokens.
- Resource server: the API that accepts the access token.
| Question | ID token | Access token |
|---|---|---|
| Comes from | OpenID Connect | OAuth 2.0 |
| Meant for | Your app (its aud is your client id) | An API (its aud is the API) |
| Answers | Who logged in, when, and how | What this request may do |
| Format | Always a JWT | JWT or opaque, the client must not care |
| Send it to your API? | No: wrong audience | Yes, that is its job |
The authorization code flow with PKCE
The flow has two legs. In the front channel (browser redirects) the user logs in and the app gets a short-lived, one-time authorization code in the URL. In the back channel (a direct POST) the code is swapped for tokens. The code travels through the URL bar, browser history, and maybe logs, so on its own it must be worthless.
PKCE (Proof Key for Code Exchange, said "pixie", RFC 7636) makes it worthless. Before redirecting, the app makes a random code verifier (43 to 128 characters), keeps it, and sends only a code challenge: the base64url SHA-256 hash of the verifier (method S256; never the plain method, which sends the verifier itself). To swap the code, the app must show the verifier, which an attacker who intercepted the code doesn't have and can't work out from the hash. Step through the flow:
Authorization code flow with PKCEStep 1 of 7: Make a secret
code_verifier (32 random bytes, 43 characters) and keeps it in the tab. It hashes it with SHA-256 to get the code_challenge, and makes a random state.Make a secret: The app makes a random code_verifier (32 random bytes, 43 characters) and keeps it in the tab. It hashes it with SHA-256 to get the code_challenge, and makes a random state.
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(digest));
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
sessionStorage.setItem("pkce", JSON.stringify({ verifier, state }));
location.assign("https://auth.example.com/authorize?" + new URLSearchParams({
response_type: "code", client_id: "web-app", redirect_uri: "https://app.example.com/callback",
scope: "openid profile", code_challenge: challenge, code_challenge_method: "S256", state,
}));
State, nonce, and redirect URIs
state: a random value the app sends and checks on the way back. It ties the callback to a login this tab started, which stops login CSRF (an attacker logging the victim into the attacker's account). PKCE also covers this, but most libraries still use both.nonce: an OIDC value placed in the ID token, so a replayed ID token from another login is rejected.redirect_uri: registered in advance and matched exactly. Wildcards here have caused real account takeovers, because the code can be sent to a page the attacker controls.
Why the implicit flow is gone
Older SPAs used the implicit flow: the provider put the access token straight into the URL fragment, #access_token=..., with no code to swap. It dates from 2012, when browsers couldn't make cross-origin POST calls. Tokens in URLs leak through browser history, extensions, and open redirects, and nothing binds them to the client.
The OAuth 2.0 Security Best Current Practice (RFC 9700, January 2025) says not to use it, and the OAuth 2.1 draft drops it, along with the password grant. Code flow with PKCE is now the one flow for every client type.
Check your understanding · Exercise 7
An attacker's browser extension reads the callback URL https://app.example.com/callback?code=abc&state=xyz. With PKCE in place, why can't it swap the code for tokens? Select all that apply.
Logging out, here and everywhere
Logout looks like the easiest feature: one button. In a token system it is the hardest, because the login exists in several places at once and each needs its own step. Miss one and "logged out" users are still logged in.
Every layer that holds a login
- This tab. Clear the access token, cached user data, and any query cache. A cached
GET /meshowing the old user's name after logout is a classic bug. - Other tabs. Tell them with a
BroadcastChannel("auth")message or astorageevent, so they show the login page instead of failing on the next click. - Your server. Call
POST /auth/logout, which deletes the session or revokes the refresh family, and expires the cookie withSet-CookieandMax-Age=0. Deleting the cookie alone is not logout: a copy would still work. - The login provider. With OIDC, the provider still remembers the user, so "Log in" signs them straight back in. Redirect to its
end_session_endpoint(RP-initiated logout) when the user expects a full sign-out, as on a shared computer. - Other devices. The phone and the work laptop each hold their own refresh token. That is "log out everywhere", below.
Log out everywhere
Users press "Sign out of all devices" after losing a phone; your system presses it for them after a password change or a reuse alarm. The goal: every credential for that user stops working, quickly. How quickly depends on the credential.
- Sessions and refresh tokens are in your store, so delete them all:
DELETE FROM sessions WHERE user_id = 81. Effective on the next request or refresh. - Access tokens already issued can't be deleted. Either accept that they live out their 5 to 15 minutes, or make APIs check a small piece of state.
There are three common ways to add that state without giving up the cheap check:
- A
jtideny-list. Put each revoked token id in Redis until the token would expire. Entries vanish as tokens expire, so the list stays tiny, and the check is one key lookup. - A per-user token version. Store
tokenVersion: 7on the user and putver: 7in each token. Logout everywhere bumps it to 8, and APIs reject older versions. One small, cacheable read per user. - "Not before" per user. Store
revokedAtand reject tokens withiatbefore it. Same idea, with timestamps.
If many services verify tokens, push revocations to them rather than making each one ask. OIDC back-channel logout sends a signed logout token from the provider straight to each app's server, and large systems often publish revocation events on a message bus to a small in-memory deny-list in each service.
What a password change should do
A password change is the moment users expect to be safe again. Revoke every other session and refresh family, keep the current one (or issue it a fresh family), bump the token version, and email the user. Many large sites do this, and show a list of active sessions with a way to end each one.
Check your understanding · Exercise 8
A user clicks "Log out" in an SPA that uses OIDC, an access token in memory, and a refresh cookie. Which steps belong in the logout? Select all that apply.
In real products: failures, numbers, and passkeys
What real libraries and providers do
- Auth0's SPA SDK keeps tokens in memory by default.
localStorageis an opt-in, and so are refresh tokens with rotation, which replace the old iframe renewal. - Firebase Authentication issues ID tokens that expire after one hour, plus a long-lived refresh token, and its web SDK keeps them in IndexedDB by default, where page scripts can read them.
- Auth.js (NextAuth) keeps the session in an encrypted
HttpOnlycookie by default, or in a database when you add an adapter. Either way the browser never sees a bearer token. - Microsoft's identity platform gives refresh tokens issued to SPAs a fixed 24-hour lifetime, much shorter than for other client types, because browser-held tokens are riskier.
- Google's OAuth access tokens last about an hour. GitHub and Google keep their own web apps on server-side session cookies, with a page listing active sessions.
How long does one theft last?
The single most useful number in a token design is the exposure window: if an attacker gets this credential once, for how long can they use it?
Failures that reach production
- Clock skew. A server whose clock runs 2 minutes fast rejects fresh tokens. Allow 30 to 60 seconds of leeway, and run NTP everywhere.
- Cookies too big. Browsers drop cookies over about 4 KB, so a JWT stuffed with 200 permissions makes logins silently fail. Keep claims small or use a session id.
- Cached private pages. A CDN stores
GET /accountfor one user and serves it to the next. Mark personalised responsesCache-Control: privateorno-store. - Auth outage, everyone logged out. If the refresh endpoint is down for 20 minutes, every access token expires. Make refresh highly available, and treat a network error differently from a real
401. - Safari clearing storage. Safari deletes script-writable storage, including
localStorageand IndexedDB, for a site the user hasn't interacted with in 7 days of Safari use, so a token there is not a reliable "remember me". Server-set cookies are not part of that rule. - Tokens in logs and URLs. Tokens in query strings end up in server logs, analytics, and
Refererheaders. Send them in headers or cookies only, and scrubAuthorizationfrom logs and error reports.
Passkeys change login, not the session
A passkey is a WebAuthn credential: a key pair made by the user's device for one site. The private key never leaves the device's secure hardware (or the user's synced password manager). At login, the browser calls navigator.credentials.get(), the device signs a fresh challenge from the server, and the server checks the signature with the stored public key.
Passkeys fix the weakest part of the old story: there is no password to phish, reuse, or leak, and the browser only uses a passkey on the site it belongs to, so a look-alike domain gets nothing. Apple, Google, and Microsoft sync them across a user's devices.
But a passkey only proves who the user is at login. After that, the server still creates a session or issues tokens as in the rest of this lesson, and XSS can still ride or steal them. Passkeys raise the bar for getting in; storage, rotation, and logout decide what happens after.
Check your understanding · Exercise 9
After switching from passwords to passkeys, which risks are mostly solved? Select all that apply.
Using it in a system design interview
Auth comes up in almost every frontend system design interview, usually as one line in the requirements ("users log in") or as a follow-up ("how does the client authenticate?"). You don't need to design an identity provider, just make three clear decisions and say why.
When to bring it up
- When you first draw the client to server arrow: say what credential rides on it.
- When the design has realtime connections: a browser WebSocket handshake carries cookies but not custom headers, so say how the socket is authenticated and what happens when the token expires mid-connection.
- When there is offline data or multiple tabs: say what happens to cached data on logout and how other tabs find out.
- When the interviewer asks about security: storage, XSS, and CSRF, with CSP (Content Security Policy) as the backstop.
What to draw and say
Walk the decision tree below out loud: each fork is one sentence, and together they take about two minutes.
A strong default for a typical web app: "Login goes through our identity provider with the authorization code flow and PKCE, run by a small BFF. The BFF keeps the tokens server-side, gives the browser a __Host- session cookie that is HttpOnly, Secure, and SameSite=Strict, checks Origin on state-changing calls, and attaches a 10-minute access token when calling our APIs. Logout revokes the session and tells other tabs. Log out everywhere deletes all the user's sessions, and APIs accept at most 10 minutes of staleness."
How deep to go by level
| Level | What a strong answer covers |
|---|---|
| Junior | Sessions vs tokens in plain words; why localStorage is readable by XSS; HttpOnly, Secure, SameSite; access token expiry and refresh |
| Mid | Short access plus revocable refresh tokens; rotation and reuse detection; CSRF defences once cookies are used; auth code with PKCE; single-flight refresh and 401 retry once |
| Senior | BFF or token handler per RFC 10017 and why memory storage still leaks via the refresh call; third-party cookie loss and silent renew; multi-tab races; logout everywhere with versions or deny-lists; exposure windows as a design input; passkeys and step-up auth |
Think first
The interviewer says: "We also have an iOS app talking to the same API. Does your cookie-based design still work?"
The browser part stays the same. A native app is a different client type: it runs the same code flow with PKCE through the system browser and keeps its refresh token in the iOS Keychain, which other apps can't read and XSS can't reach.
The API can accept both: a session cookie through the web BFF, and bearer access tokens from the native app. A BFF serves one client type, which is the point of a backend for frontend.
Summary
- A session id points at state on the server and can be revoked at once; a JWT carries signed claims, needs no lookup, and can't be taken back before it expires. Most systems use a session at the edge and short JWTs inside.
- XSS can read
localStorage,sessionStorage, and (with effort) memory, but not anHttpOnlycookie. Cookies are exposed to CSRF instead, whichSameSite,Originchecks, and safeGETs close cheaply. HttpOnlystops theft, not use: an XSS script can still act as the user while the tab is open. Treat XSS prevention and CSP as the real defence.- Pair short access tokens (minutes) with revocable refresh tokens (days). Rotate refresh tokens and revoke the whole family when a used one comes back.
- Refresh once at a time: share one promise per tab, a Web Lock across tabs, and allow a few seconds of reuse grace on the server. Retry a
401once only. - Iframe silent refresh depends on third-party cookies, which Safari blocks and Firefox partitions. Use rotation, a same-site auth domain, or a BFF.
- Browser apps use the authorization code flow with PKCE; the implicit flow is gone. ID tokens are for your app, access tokens are for your API.
- Logout touches every layer: this tab, other tabs, the server session, the provider's session, and other devices. The access token lifetime is your logout-everywhere delay unless APIs check a version or deny-list.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
Where would you store auth tokens in a single-page app, and why?
Ideally nowhere in JavaScript. A BFF on the same site runs the OAuth flow, keeps the tokens on the server, and gives the browser an HttpOnly; Secure; SameSite=Strict session cookie with the __Host- prefix, as RFC 10017 recommends. XSS can't read it, so a bug can't turn into stolen credentials used from the attacker's machine.
Cookies bring CSRF back, so I'd rely on SameSite, check Origin on state-changing requests, and keep GET free of side effects.
If there's no BFF, I'd keep a short access token in memory and the refresh token in an HttpOnly cookie with rotation, knowing an XSS script can still call the refresh endpoint while the tab is open. I'd avoid localStorage for anything long-lived. CSP is the backstop in every option.
Question 2
Sessions or JWTs?
Sessions are a random id pointing at server state: instant revocation, small cookies, a lookup per request. JWTs carry signed claims: no lookup and any service can verify, but you can't revoke them before they expire.
For the browser I'd use a session, because revocation matters most where users are. Inside the backend, short-lived JWTs minted from that session give cheap checks across services. Long-lived JWTs held by the browser are the combination I'd avoid.
Question 3
How does refresh token rotation detect theft, and what can go wrong with it?
Each refresh returns a new refresh token and marks the old one used. All tokens from one login share a family id. If a used token is presented again, two parties hold copies, so the server revokes the family and both must log in again.
What goes wrong: tabs refreshing at once look like reuse and log the user out, and lost responses cause retries with the old token. Fix with single-flight refresh per tab, a Web Lock across tabs, and a short grace window on the server. Rotation also can't catch a thief who uses the token while the user is away, so idle and absolute lifetimes still matter.
Question 4
Why does an SPA need PKCE, and why not the implicit flow?
An SPA is a public client: it can't hold a client secret. The authorization code returns through a URL, where it can leak. PKCE binds the code to a random verifier that only the app knows, so a leaked code is useless.
The implicit flow put the access token itself in the URL fragment, where it leaks through history, extensions, and open redirects. RFC 9700 says not to use it and OAuth 2.1 removes it; auth code with PKCE replaces it for every client type.
Question 5
A user reports their laptop was stolen. How do you log them out everywhere, and how fast does it take effect?
Delete all their sessions and refresh token families in the store. That takes effect on the next request for session-based clients and on the next refresh for token-based ones.
Access tokens already issued live until expiry, so the delay equals the access token lifetime, say 10 minutes. If that's too long, bump a per-user token version that APIs check, or deny-list the token ids until they expire. Then notify the user and show the remaining active sessions.
Practice it
These design problems lean on authentication and token storage.
- Design a recently viewed products featureEasy
- Design a customer support chat widgetMedium
- Design sign up and sign inMedium
- Design permission-based UIMedium
- Design state sync across browser tabsMedium
- Design a QR code event check-in appMedium
- Design an e-signature workflow like DocuSignMedium
- Design LinkedInHard
- Design a web-based terminalHard
- Design a stock trading appHard
References
- RFC 9700: Best Current Practice for OAuth 2.0 Security Why the implicit flow is out, and the rules for refresh tokens in public clients.
- OAuth 2.0 for Browser-Based Applications (RFC 10017) The BFF architecture for browser apps and its cookie rules.
- RFC 7636: Proof Key for Code Exchange (PKCE)
- RFC 7519: JSON Web Token (JWT)
- OpenID Connect Core 1.0 ID tokens, nonce, and the claims they carry.
- MDN: Using HTTP cookies HttpOnly, Secure, SameSite, and cookie prefixes.
- OWASP Session Management Cheat Sheet
- MDN: Web Locks API Running one refresh at a time across tabs.
- web.dev: Passkeys
Read next
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- Cross-site request forgery (CSRF)An attack where another site makes the user's own browser send a request to your app, with the user's cookies attached, so the app thinks the user asked for it. SameSite cookies, tokens, and origin checks stop it.
- CORSThe rules that let a page read responses from another origin, and the headers a server sends to allow it: a browser feature that relaxes the same-origin policy to protect users' data, not a wall that protects your server.
- Client storage optionsThe six places a web app can keep data on the user's device (cookies, localStorage, sessionStorage, IndexedDB, the Cache API, and OPFS), how big, how fast, and how long-lived each one is, and how to pick the right one for each kind of data.
- Backend for frontend (BFF)A small server, usually owned by the frontend team, that gathers data from many backend services and returns exactly what one screen of one kind of client needs.
- Content Security PolicyA response header that tells the browser which scripts, frames, and connections a page may use, so an injected script is blocked even when an XSS bug slips through.