cf.completefrontend
My progress
FUNDAMENTALS · MODULE 11

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.
A sequence diagram between the browser, the login server, and the API. The browser sends POST /login with the password once. The login server answers with a credential: a session id or a JWT. Then the browser calls GET /invoices with the credential and the API answers 200. It calls GET /invoices/42 with the same credential and the API answers 200 again. The password is never sent again.BrowserLogin serverAPIPOST /login + passwordcredential: sid or JWTGET /invoices + credential200 invoicesGET /invoices/42 + credential200 invoice 42password sent once, credential sent every time
The password crosses the wire once. After that, a credential the server handed out stands in for it on every request, so whoever holds that credential is the user.

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

  1. Should the server remember sessions, or should the token carry everything?
  2. Where should the credential live, so that script injection (XSS) or a forged cross-site request (CSRF) gets the attacker as little as possible?
  3. How do short access tokens and long refresh tokens work together, and how does rotation catch a stolen one?
  4. How does "Sign in with Google" work for an SPA, and why is PKCE required?
  5. 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.

Two halves side by side. Left, a session: the request carries sid=9fK2, the API looks it up in Redis and finds user 81. To revoke it, delete the row. Right, a JWT: the request carries a signed token with the user id and expiry inside, the API verifies the signature with a public key in about 0.05 milliseconds and does no lookup. To revoke it, you can only wait for it to expire.Session: look it upsid=9fK2...APIRedissid → user 81revoke: delete the rowJWT: check the signature{ sub: 81, exp } + sigAPIverify with keyabout 0.05 msno lookup, no networkrevoke: wait for exp
The session keeps the truth on the server, so deleting a row logs the user out. The JWT carries the truth with it, so any service can check it alone, and nobody can call it back before 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

  1. 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.
  2. It stores sess:9fK2... -> { userId: 81, created: 1760851200, lastSeen: 1760851200 } in Redis with an expiry.
  3. It replies with Set-Cookie: __Host-sid=9fK2...; Path=/; Secure; HttpOnly; SameSite=Lax.
  4. 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 updates lastSeen.

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.

A JWT as three base64url parts joined by dots: header, payload, and signature. Decoded, the header says alg ES256 and key id k7: pin the algorithm. The payload says iss auth.example.com, who issued it; sub 81, the user id; aud api.example.com, which API may use it; exp 1760852100, the expiry in seconds. The signature is ES256 over the header and payload, and breaks if any byte changes.eyJhbGci...eyJzdWIi...Xq3vT9kR.....headerpayloadsignaturealg: ES256, kid: k7pin the algorithmiss: auth.example.comwho issued itsub: 81the user idaud: api.example.comwhich API may use itexp: 1760852100expiry, in secondsES256 over both partsbreaks if a byte changes
The header and payload are only base64url, not encrypted: anyone holding the token can read them. Only the signature needs the key, and the verifier must check it with an algorithm it chose, not the one in alg.
  • iss (issuer): who made the token, like https://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, like https://api.example.com. An API must reject tokens meant for someone else.
  • exp and iat: 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?

Server-side session

Opaque random id in an HttpOnly cookie

Session data in Redis or the database

Pros

  • Instant revocation and role changes
  • Tiny cookie
  • Easy "active devices" page

Cons

  • A store lookup on every request
  • Store must be highly available
  • Every service needs the store, or a gateway

Long-lived JWT in the browser

Signed token with user claims

Sent as a bearer header or a cookie

Pros

  • No lookup, any service can verify
  • Simple to start

Cons

  • Cannot revoke without adding state
  • Stale roles
  • Theft gives long access

Session at the edge, short JWTs inside

Browser holds a session cookie, often to a BFF (backend for frontend: a small server for one app)

Edge or BFF mints 5 to 15 minute JWTs for internal APIs

Pros

  • Revocation where it matters
  • Cheap checks for internal traffic
  • Tokens never reach the browser

Cons

  • One more component to run
  • Two lifetimes to reason about

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 an Authorization header by hand.
  • sessionStorage: like localStorage but 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.fetch and see the header on the next call.
  • HttpOnly cookie: set by the server, sent by the browser on matching requests, and invisible to document.cookie and every other script API.
A comparison matrix of three places for a credential: web storage (localStorage or sessionStorage), memory, and an HttpOnly cookie. Can XSS read and steal it: web storage yes, memory harder, cookie no. Can XSS use it inside the tab: yes for all three. Does CSRF ride on it: web storage no, memory no, cookie no if SameSite is Lax or Strict. Survives a reload: web storage yes, memory no, cookie yes. Works with an API on another site: web storage yes, memory yes, cookie only same-site.web storagememoryHttpOnly cookieXSS reads and stealsyeshardernoXSS uses it in the tabyesyesyesCSRF rides on itnonono, if LaxSurvives a reloadyesnoyesCross-site APIyesyessame-site
No column is all good. XSS can use every option while it runs; the real difference is whether it can carry the credential away. Cookies trade that risk for CSRF, which SameSite mostly closes.

A cookie is only as safe as its attributes. For a session or refresh cookie, a good default line looks like this:

http
Set-Cookie: __Host-sid=9fK2xQ...; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=2592000
A session cookie that scripts can't read, that never goes over plain HTTP, and that other sites can't send with a POST.
  • HttpOnly: scripts can't read it, the single biggest win against token theft. A cookie set from JavaScript can never be HttpOnly.
  • Secure: only sent over HTTPS. Don't drop it "for now" on an http:// staging site.
  • SameSite=Lax: not sent on cross-site subrequests or cross-site POST, but sent when the user follows a link to your site, so they arrive logged in. Strict also drops it on those links, which suits a BFF behind an SPA (the BFF lesson explains when); None sends it everywhere and needs Secure. Chrome treats a cookie with no SameSite as Lax, but other browsers differ, so always set it.
  • __Host- prefix: the browser only accepts the cookie if it is Secure, has Path=/, and has no Domain, so a sibling subdomain can't overwrite it.
  • Max-Age or Expires: 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.

Stored as:
Attack:
Attack: XSS: read it and send it away, against localStorage. The attacker gets a 30-day token, usable from any machine, for 30 days, until exp. Against this storage, the three attacks give: XSS: read it and send it away: a 30-day token; XSS: call the API from the page: acts as the user; CSRF: form POST from evil.example: nothing.injected scriptlocalStoragea 30-day tokenworks from: any machinelasts: 30 days, until explocalStorage against every attacka 30-day tokenXSS stealacts as the userXSS in the tabnothingCSRF
Top: the attack you picked and what it yields. Bottom: the same storage against all three attacks, so you can see its weak side at a glance.

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.

A state machine for one refresh token. Login or a refresh issues it and it becomes active. From active, using it once moves it to used, and a new token takes its place. From active, the idle or absolute time limit moves it to expired. From active, logout or a password reset moves it to family revoked. From used, being presented again moves it to family revoked, because someone has a copy.issuedactiveusedkept to spot reuseexpiredfamily revokedloginused oncetime limitlogout or resetreused!
A healthy token goes from active to used exactly once. The arrow from used to revoked is reuse detection: a used token should never come back, so when it does, the whole family dies.

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.

A sequence diagram between the browser, the auth server, and a thief who copied refresh token R1. The browser refreshes with R1 and gets access token A2 and refresh token R2; R1 is marked used. Later the thief sends R1. The auth server sees R1 was already used, revokes the whole family, R1 and R2, and answers 401 to the thief. The browser's next refresh with R2 also gets 401, and the user logs in again.BrowserAuth serverThiefrefresh with R1A2 + R2 (R1 used)has a copy of R1refresh with R1R1 already usedrevoke family: R1, R2401refresh with R2401: log in again
Every refresh token works once. When a used one comes back, the server can't tell the thief from the user, so it ends the whole chain and both must prove themselves again.
ts
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 };
}
A refresh endpoint with rotation and reuse detection. Only hashes are stored, so a database leak doesn't leak usable tokens.

Choosing the lifetimes

LifetimeTypical valueWhat it bounds
Access token5 to 15 minutesHow long a stolen access token works, and how long a revoked user keeps API access
Refresh token idle1 to 14 daysHow long a device can sit unused and still stay logged in
Refresh family absolute30 to 90 daysHow long one login can last, however active, before a real sign-in again
Reuse grace window0 to about 30 secondsHow 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.

Two timelines from 0 to 360 milliseconds. Without coordination, tabs A, B, and C each send refresh token R1 at 0, 10, and 20 milliseconds. Tab A gets R2; tabs B and C present R1 after it was used, which looks like reuse, so the family is revoked and every tab is logged out. With a Web Lock, tab A refreshes R1 to R2 from 0 to 120 milliseconds while B and C wait. Then B refreshes R2 to R3 until 240, and C refreshes R3 to R4 until 360. Nobody is logged out.no coordinationTab AR1 → R2Tab BR1: reused!Tab CR1: reused!family revoked: all logged outWeb Lock: one at a timeTab AR1 → R2Tab BR2 → R3Tab CR3 → R40120240360 mstime
The cookie jar is shared by all tabs, so once a lock makes them take turns, each refresh sends the newest token. Better still, tabs B and C can take A's new access token over BroadcastChannel and skip their own refresh.

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:

  1. 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.
  2. 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.
  3. A short grace window on the server, for cases the client can't see, such as a retry after a lost response.
ts
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;
}
Single flight inside the tab (the shared promise) and across tabs (the named lock). The refresh cookie rides along on its own.

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.
QuestionID tokenAccess token
Comes fromOpenID ConnectOAuth 2.0
Meant forYour app (its aud is your client id)An API (its aud is the API)
AnswersWho logged in, when, and howWhat this request may do
FormatAlways a JWTJWT or opaque, the client must not care
Send it to your API?No: wrong audienceYes, 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

A sequence diagram between your app, the login server, and your API, built up step by step. The app makes a secret verifier and its SHA-256 challenge, sends the challenge and state, the user logs in, the login server redirects back with a one-time code, the app swaps the code plus the verifier for an access token and an ID token, then calls the API with the access token.Your appLogin serverYour APIverifier → challenge
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.

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.

ts
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,
}));
Starting the flow in a browser. The verifier waits in sessionStorage for the callback in the same tab.

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.

Four zones that each hold part of a login. This browser: this tab, where logout clears tokens and caches, and other tabs, told over BroadcastChannel. Your server: the session or refresh family, which logout deletes while expiring the cookie. The login provider: its own session, ended through the end_session endpoint. Other devices: a phone, whose next refresh fails once its family is revoked, and access tokens already issued, which live until they expire unless APIs check a token version.This browserthis tabclear tokens and cachesother tabsBroadcastChannel: logged outYour serversession or refresh familydelete it, expire the cookieLogin provider (OIDC)provider sessionredirect to end_session_endpointOther devicesphonenext refresh failsissued access tokenslive until exp, or check version
A logout button that only clears this tab leaves four other layers logged in. The server step is the one that makes a copied credential useless; the provider step decides whether "Log in" asks for a password again.

Every layer that holds a login

  1. This tab. Clear the access token, cached user data, and any query cache. A cached GET /me showing the old user's name after logout is a classic bug.
  2. Other tabs. Tell them with a BroadcastChannel("auth") message or a storage event, so they show the login page instead of failing on the next click.
  3. Your server. Call POST /auth/logout, which deletes the session or revokes the refresh family, and expires the cookie with Set-Cookie and Max-Age=0. Deleting the cookie alone is not logout: a copy would still work.
  4. 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.
  5. 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 jti deny-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: 7 on the user and put ver: 7 in each token. Logout everywhere bumps it to 8, and APIs reject older versions. One small, cacheable read per user.
  • "Not before" per user. Store revokedAt and reject tokens with iat before 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. localStorage is 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 HttpOnly cookie 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?

A bar chart on a log scale of how long an attacker can use one stolen or abused credential. A 30-day JWT in localStorage: 30 days, from anywhere. A 7-day refresh token without rotation: up to 7 days. An HttpOnly cookie under XSS: only while the tab is open, say one hour. A 15-minute access token: at most 15 minutes. A server session that was revoked: gone on the next request, seconds.30-day JWT in localStorage: 30 days, from anywhere7-day refresh token, no rotation: up to 7 daysHttpOnly cookie under XSS: while the tab is open, say 1 h15-min access token: at most 15 minrevoked server session: gone on the next requestlog scale: each step right is 10 times longer
The scale is logarithmic: each step to the right is ten times longer. Moving from a 30-day bearer token to a revocable cookie session shrinks the window by about three orders of magnitude.

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 /account for one user and serves it to the next. Mark personalised responses Cache-Control: private or no-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 localStorage and 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 Referer headers. Send them in headers or cookies only, and scrub Authorization from 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 decision tree. First question: can you run a BFF? If yes, use a BFF with a session cookie, so tokens never reach the browser. If no, ask: is the API on the same site as the app? If yes, use an HttpOnly cookie, either a session or a refresh cookie. If no, keep a short access token in memory with a rotated refresh token. Whatever the branch: SameSite, Origin checks, CSP, and short access tokens.can you run a BFF?yesnoBFF + session cookietokens never in the browserAPI on the same site?yesnoHttpOnly cookiesession or refresh cookiememory token+ rotated refreshalways: SameSite, Origin checks, CSP, short tokens
Each fork is one sentence in an interview. Most teams can reach the left branch; the right one is the fallback when the API lives on another site you can't proxy.

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

LevelWhat a strong answer covers
JuniorSessions vs tokens in plain words; why localStorage is readable by XSS; HttpOnly, Secure, SameSite; access token expiry and refresh
MidShort 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
SeniorBFF 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?"

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 an HttpOnly cookie. Cookies are exposed to CSRF instead, which SameSite, Origin checks, and safe GETs close cheaply.
  • HttpOnly stops 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 401 once 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?

Question 2

Sessions or JWTs?

Question 3

How does refresh token rotation detect theft, and what can go wrong with it?

Question 4

Why does an SPA need PKCE, and why not the implicit flow?

Question 5

A user reports their laptop was stolen. How do you log them out everywhere, and how fast does it take effect?

Practice it

These design problems lean on authentication and token storage.

References

Read next