Part 1 · 3 chapters · ~30 min

Authentication Flows

Where the credential lives (cookies, tokens, the hybrid with rotation), expiry in the middle of a transfer handled without losing the form, logout and multi-tab refresh, factors by strength with device trust and step-up per action, passkeys through WebAuthn, and the lockout screen as the most important screen in the product.

3

Sessions, tokens and refresh

code
// the request layer: silent refresh, one retry with the same idempotency key, and a re-auth modal that keeps the flow
let refreshing: Promise<void> | null = null
async function refresh() {
  // one refresher per browser: a Web Lock serialises tabs; the winner rotates the token and broadcasts
  return navigator.locks.request('auth-refresh', async () => {
    const r = await fetch('/auth/refresh', { method: 'POST', credentials: 'include' })   // the refresh token is an HttpOnly cookie scoped to this path
    if (!r.ok) throw new SessionExpired()
    const { accessToken, expiresAt } = await r.json()
    auth.set(accessToken, expiresAt)                                                      // in memory; never localStorage
    channel.postMessage({ type: 'token', accessToken, expiresAt })                      // other tabs pick it up
  })
}
export async function api(input: RequestInfo, init: RequestInit & { idempotencyKey?: string } = {}) {
  if (auth.expiresSoon()) await (refreshing ??= refresh().finally(() => (refreshing = null)))   // proactive
  const send = () => fetch(input, { ...init, headers: { ...init.headers, Authorization: `Bearer ${auth.token}`, ...(init.idempotencyKey ? { 'Idempotency-Key': init.idempotencyKey } : {}) } })
  let res = await send()
  if (res.status === 401) {                                                                // reactive: once
    try { await (refreshing ??= refresh().finally(() => (refreshing = null))); res = await send() }   // same body, same idempotency key
    catch (e) { if (e instanceof SessionExpired) { await reauthenticate(); res = await send() } else throw e }   // modal over the current screen; form state kept
  }
  if (res.status === 403) { const { requiredFactors } = await res.clone().json(); if (requiredFactors) { await stepUp(requiredFactors, init); res = await send() } }   // step-up inline
  return res
}
// reauthenticate(): opens the modal, resolves when the user has re-proven presence; the caller's state (the review step) is untouched
// on SessionExpired with no re-auth: auth.clear(); queryClient.clear(); navigate('/login?return=' + current)   // never leave the previous user's data in the cache
where the credential lives
  1. Session cookies: an opaque id, HttpOnly (scripts cannot read it), Secure, SameSite=Lax (no cross-site POSTs: the Browser course part 9), attached by the browser. Idle timeout 15 to 30 minutes for banking; absolute 8 to 24 hours; revocation is deleting the server record. The safest default for a first-party web app.
  2. Tokens: a short access token (5 to 15 minutes, Bearer header, stateless verification, no early revocation) and a refresh token that mints new ones. In memory for access (lost on reload: fine); an HttpOnly cookie scoped to the refresh endpoint for refresh; never localStorage (any XSS reads it: the most common fintech frontend vulnerability).
  3. The hybrid: access in memory, refresh in a cookie, a session record server-side so refresh is revocable; proactive refresh a minute before expiry and reactive on a 401, once, then retry the original request with the same idempotency key; rotation (each refresh token single-use; reuse revokes the chain) detects theft at the second use.
expiry mid-flow, logout, tabs
  1. The test of an auth layer: the token expires on the review step of a transfer. Wrong: a 401, a redirect to login, the form gone. Right: silent refresh and retry; if the session is truly over, a re-authentication modal over the current screen with the form state kept (memory plus sessionStorage), and the submit continues after. The user never leaves the review step.
  2. Logout and revocation: clear the client, revoke the session and the chain server-side; "log out everywhere" revokes all (a sessions list the user can see); a password change revokes the others; any 401 after a failed refresh means logged out, and the query cache is cleared so the previous user's data does not survive into the next login.
  3. Multiple tabs: two refreshes race and, with rotation, revoke each other. One refresher per browser (a Web Lock, or the FSD course M7's leader tab) and a BroadcastChannel that shares the new token and the logout.
SESSIONS, TOKENS AND REFRESH
where the credential lives, how long it lasts, and what the client does when it expires mid-flow
swipe the figure sideways, or tap expand for full screen
1/6
session cookies
Session cookies: the server stores the session; the cookie is an id. HttpOnly keeps scripts from reading it (XSS cannot steal it); Secure keeps it off plain HTTP; SameSite=Lax keeps it off cross-site POSTs (the Browser course part 9's CSRF defence); the browser attaches it automatically. Lifetime: idle timeout (15 to 30 minutes for banking) and absolute timeout (8 to 24 hours). Revocation is immediate: delete the server record.
4

MFA, device trust and step-up

factors by strength
  1. SMS codes (interceptable, SIM-swappable, phishable) → email codes (as safe as the mailbox) → TOTP apps (phishable in real time, not swappable) → push approval with a number match (defeats fatigue attacks) → passkeys and FIDO2 keys (origin-bound: a phishing site cannot obtain a valid assertion). Strength is resistance to phishing and swapping; only origin-bound factors resist both. Regulators increasingly reject SMS for high-value actions.
device trust and step-up
  1. A trusted device (a long-lived HttpOnly device cookie or a keystore entry) counts as the possession factor for low-risk actions; a new device prompts, then is trusted; trust decays after 30 days, a password change, or a country change. The user sees the devices and can revoke them: their own audit.
  2. Step-up by action: login on a trusted device needs the password; viewing a balance needs nothing more; a transfer under a threshold needs a PIN; above it a strong factor; changing the payout account or the phone number needs a strong factor, a cooling period and a notification to the old channel. The policy is server-side; the client asks for exactly the factor a 403 { requiredFactors } names, inline, keeping the flow.
  3. The prompt names the action ("Approve a transfer of ₦250,000 to ADEOLA FAITH?" with a number to match), shows the requesting device, allows a counted retry, and never auto-submits a code field on the last digit.
  4. Fallbacks: recovery codes shown once with a prompt to save them; a second enrolled factor on another device; a human path with identity verification (part 2), a mandatory delay and notifications to every channel.
  5. The client's rules: never store factor secrets or log codes; bind every assertion to the action and origin; expire a step-up grant after one action or minutes; show sessions and devices; meet every prompt as a moment of fear with a sentence that says what is happening.
MFA, DEVICE TRUST AND STEP-UP
factors by strength, a trusted device as a factor, and asking for more only when the action needs it
swipe the figure sideways, or tap expand for full screen
1/6
the factors
The factors, weakest to strongest: SMS one-time code (interceptable, SIM-swappable, phishable); email code (as safe as the mailbox); TOTP from an authenticator app (phishable in real time, not swappable); push approval (phishable by fatigue unless a number match is required); passkeys and FIDO2 keys (origin-bound: a phishing site cannot obtain a valid assertion). Regulators increasingly reject SMS for high-value actions.
5

Passkeys, and the UX of being locked out

passkeys
  1. What one is: a key pair; the private key in the device's secure enclave (synced through the platform keychain, or device-bound), the public key registered with the site; login is a signed challenge bound to the origin, unlocked by biometric or PIN. Nothing to type, nothing to phish, nothing for the server to leak.
  2. Registration (navigator.credentials.create with the relying party id, the user, a challenge, resident key and user verification) stores the credential id and public key; login (navigator.credentials.get, or conditional UI in the username field's autofill) returns an assertion the server verifies against the public key, the origin and the challenge.
  3. The UX: one button or the autofill suggestion, then the platform's sheet. Enrol at a calm moment (after a successful login, not during a stressful action); keep a second method until the user has a synced passkey or one on more than one device; list passkeys in the devices view with where they were created; say which are synced and which are device-bound.
the lockout screen
  1. Causes: a forgotten password with a lost phone; a new phone without a synced passkey; a changed number; too many attempts (theirs or an attacker's); a fraud hold. Most lockouts are legitimate users, frightened.
  2. The screen: one sentence first: "We could not verify it was you. Your money is safe and nothing has changed." Then paths in order of speed: another device where they are signed in; a recovery code; a second factor; identity verification with an honest time ("usually within 2 hours; up to 1 business day") and a reference number; a way to reach support without logging in. Never "contact support" alone; never a dead end.
  3. Protections: every recovery path notifies every channel ("someone started account recovery; if this was not you, tap here"); a delay before high-risk actions after recovery (24 hours before a payout-account change: the CBA module's fraud pattern); rate limits that do not lock the real user out of recovery itself. The attacker and the real user walk the same path; the delay and the notifications are how the real user wins.
the exercise
Expire your own session on the review step of your product's riskiest action. If the form is gone when you get back, that is the first thing to fix; then try to lock yourself out and read the screen as a frightened person would.
PASSKEYS, AND THE UX OF BEING LOCKED OUT
the strongest factor with the simplest prompt, and the screen for the worst day
swipe the figure sideways, or tap expand for full screen
1/6
registration
Registration: the server issues a challenge and the user's identity; the client calls navigator.credentials.create with the relying party id (the domain), the user, the challenge, and options (resident key so the passkey can log in without a username; user verification required); the platform prompts for biometric; the client sends the public key and attestation to the server, which stores the credential id and public key against the user.