cf.completefrontend
My progress
SYSTEM DESIGN · #58

Design sign up and sign in

The front door of a SaaS app: sign up, sign in, sign out, password reset, MFA, social sign-in and passkeys, where the hard part is keeping the session safe from XSS and CSRF, never telling an attacker which accounts exist, and still letting real people in quickly on any device.

The question

Design sign up and sign in for our SaaS product. People can use an email and password, Google, or a passkey. Some workspaces require MFA. They must be able to reset a forgotten password and sign out of every device.

Walk me through the screens, where the session lives in the browser, how social sign-in and passkeys work from the frontend, how you handle errors without helping attackers, and what happens when a session expires while someone is typing.

Real-life examples: Sign in to GitHub, with passkeys, a password, or a security key, Notion and Slack sign-in by email code or Google, Google account sign-in, which asks for the email first and the method second, Stripe dashboard sign-in with TOTP and security keys, Linear sign-in with Google, an email link or company SAML

Requirements: the front door of the product

Every SaaS product has the same front door: a page where people create an account, and a page where they come back. Behind those two pages sit five more flows that people only notice when they break: sign out, forgot password, a second factor (MFA, multi-factor authentication), sign-in with Google or another provider, and passkeys, the new kind of credential that replaces the password with a key stored on your phone or laptop.

This is a frontend question, but the server owns the truth. The server checks passwords, issues sessions and decides who can see what. The frontend owns everything around that: which method to offer, how the forms behave with password managers, where the session lives in the browser, how redirects come back safely, what errors say, and what happens to the rest of the app when the session ends.

Many of the classic ways to break a sign-in page are frontend mistakes: a token in localStorage read by an injected script, an open redirect after sign-in, an error message that confirms an account exists.

Questions to ask

Each question changes a real decision later in the guide. The middle column is what this guide assumes.

QuestionAnswer we assumeWhat it changes
Who signs in?Consumers and small teams; 2 million accounts, 150,000 sign-ins a dayConversion matters as much as security; support cost of lockouts is real
Which methods?Email and password, email code, Google, passkeysWe need an identifier-first screen to route each person to their method
Is there an enterprise SSO (SAML) tier?Yes, some workspaces sign in through their company identity providerThe email domain decides the method, another reason to ask for the email first
One app or many subdomains?One SPA at app.example.com, API at api.example.com, marketing at example.comCookie domain, SameSite, CORS and where the BFF lives
Must some actions re-check identity?Yes: changing email, adding a passkey, billing, deleting a workspaceStep-up authentication with a short freshness window
Mobile apps?Native apps exist and share the same accountsPasskeys must be bound to a domain the apps can also use
Compliance?SOC 2, GDPR, WCAG 2.2 AAAudit log of sign-ins, accessible authentication, no cognitive tests

Functional requirements

  • Sign up with email (verified by a link or code), with Google, or with a passkey created right after the first sign-in.
  • Sign in with whatever method the account has: password, email code, Google, passkey, or company SSO.
  • A second factor when the account or workspace requires it: authenticator app (TOTP), security key, or SMS as a last resort.
  • Forgot password and account recovery that never reveal whether an email is registered.
  • Return to the page you were on (a deep link, a half-written comment) after signing in.
  • Sign out of this device, and sign out of every device from settings.
  • Re-authentication (step-up) before sensitive actions, without losing the form the person is in.

Non-functional requirements

  • Security: session secrets unreachable from JavaScript, CSRF-safe writes, no account enumeration, rate limits on every guessable step, validated redirects.
  • Speed: the sign-in page is a landing page for many people. Aim for LCP (Largest Contentful Paint, when the main content appears) under 1.5 seconds on a mid-range phone, a JavaScript budget under 80 KB gzipped, and no third-party scripts except the identity provider button.
  • Conversion: a returning user with a passkey or a saved password signs in with 1 or 2 taps. Every extra step loses people: measure the drop-off of each screen.
  • Accessibility: WCAG 2.2 AA, including success criterion 3.3.8 Accessible Authentication: no step may require remembering, transcribing or solving something unless there is a way around it.
  • Reliability: a sign-in outage is a full product outage. The page must load even if the Google button or the CAPTCHA script fails.

Out of scope

We leave out the server side of password hashing (the backend uses a slow hash such as Argon2id), building our own identity provider, SAML internals, and roles and permissions after sign-in (see the permission-based UI problem). We mention them only where they shape the frontend.

Check your understanding · Exercise 1

Which of these answers would change the frontend design the most? Select all that apply.

What makes sign-in hard

A sign-in page looks like two inputs and a button. Five properties of the problem make it much harder than that, and each one drives a chapter later on.

  1. It is the most attacked page you own. Bots try leaked email and password pairs from other sites (credential stuffing) at thousands of attempts a minute. Every message, status code and response time is read by someone trying to learn which accounts exist.
  2. It holds the one secret that unlocks everything. After sign-in, the browser carries a session: a cookie or a token. If any script on any page of your origin can read it, a single XSS bug anywhere in the app becomes a full account takeover.
  3. It crosses origins and devices. Google sign-in leaves your site and comes back. A magic link opens in a different browser than the one that asked for it. A passkey can live on a phone while the person sits at a laptop. Each crossing needs state that survives it and checks that it was not forged.
  4. Security and conversion pull in opposite directions. Every extra step, CAPTCHA or code costs real sign-ups. Every shortcut costs real accounts. The design has to put friction only where risk is.
  5. Sessions end in the middle of work. A token expires while someone writes a long comment. Another tab signs out. An admin revokes all sessions. The app must notice, ask again, and keep the unsaved work.

Here is how the rest of the guide maps to those five.

  • The sign-in methods chapter decides which credentials we accept and how one screen routes to each.
  • The forms chapter makes passwords and codes work with password managers and assistive tech.
  • The session chapter decides where the secret lives.
  • The OAuth and passkeys chapters handle the cross-origin and cross-device flows, and the MFA chapter adds the second factor.
  • The errors chapter keeps the page from leaking accounts.
  • The redirects and session end chapters handle deep links and sessions that end in the middle of work.
Wireframe of the sign-in screen at app.example.com/sign-in. A heading, Sign in to Example. One Email field holding ana@example.com, with the browser's autofill menu open below it offering a passkey for ana@example.com. A Continue button. Below it, the word or, and a Continue with Google button. Notes on the right: the email field has autocomplete username webauthn; passkeys appear in the autofill menu; Google uses a full-page redirect through the BFF; the password is asked on the next step.app.example.com/sign-inSign in to ExampleEmailana@example.compasskey: ana@example.comContinueorContinue with Google1 email fieldusername webauthnpasskeys appear inthe autofill menuGoogle: full-pageredirect via BFFpassword: next step
One field, one primary button. The passkey offer costs no space: it lives in the autofill menu of a field marked autocomplete="username webauthn". The password, code or SSO screen comes next, chosen by the server.

The wireframe above is the screen we are designing toward: one email field first, a passkey offered from the browser's autofill menu, a Google button, and a single Continue. The method screen that follows depends on the account. A first-time visitor never sees a wall of five buttons and three inputs.

Think first

An attacker cannot see your server code. Name three things they can still observe on a sign-in page that might tell them whether ana@example.com has an account.

Choosing the sign-in methods

The naive design is the classic form: email, password, a Sign in button, and below it a row of social buttons. It works until the requirements arrive. A company workspace must go through its own SAML provider, so the password field is wrong for those people.

Someone who signed up with Google types a password they never set and gets "wrong password". A person with a passkey still sees a password box and assumes they need one. "I can't sign in" is one of the most common kinds of support ticket in a SaaS help desk, and many of those tickets come from people picking the wrong method.

So the first decision is which credentials to accept at all. Each has a different cost in security, conversion and support.

Decision Which sign-in methods should the product offer?

Password

The person picks a secret at sign-up. The server stores a slow hash and compares on each sign-in.

Password managers fill it in one tap if the form is built right.

Pros

  • Everyone understands it; works offline from email and phones.
  • Fast for people with a password manager.

Cons

  • Reused passwords make credential stuffing work.
  • Phishable: a fake page collects it.
  • Needs a reset flow, breach checks and rate limits.

Email link or email code

The person types an email; the server mails a one-time link or a 6-digit code that expires in about 10 minutes.

Clicking the link or typing the code creates the session.

Pros

  • Nothing to remember, no password database to leak.
  • Sign-up and sign-in become the same flow.

Cons

  • Slow: switching to the inbox, waiting for delivery, spam folders.
  • Only as safe as the mailbox.
  • Corporate link scanners open links before the person does and can burn a one-time token.

Social sign-in (OAuth and OIDC)

The browser goes to Google, the person approves, Google sends the browser back with a one-time code, our server trades it for an ID token that names the person.

Pros

  • One or two taps for people already signed in to Google.
  • Email arrives already verified.
  • No password for us to store.

Cons

  • Depends on a third party being up.
  • People forget which provider they used.
  • Redirects and popups add edge cases.

Passkeys (WebAuthn)

The device creates a key pair for our domain; the private key stays in the platform authenticator or password manager.

Sign-in signs a server challenge after a fingerprint, face or device PIN.

Pros

  • Phishing-resistant: the key only works on our real domain.
  • Nothing reusable to leak from our server.
  • Fastest sign-in once set up.

Cons

  • Needs a fallback for devices without one.
  • Sync and cross-device flows still confuse some people.
  • Recovery still depends on another method.

What we pick: Offer all four, but not all at once. Passkeys are the goal for returning users, Google covers people who want one tap, and email codes replace passwords for people who have neither. Keep passwords for existing accounts and people who prefer them. Then the first screen asks only for the email, and the server answers with the next step without saying whether the account exists.

Identifier-first: ask who, then ask how

An identifier-first flow splits sign-in into two screens. Screen one asks only for the email (the identifier), and the frontend posts it to POST /api/auth/lookup. The server answers with the next step: a password screen, an email code, or a redirect to the company SSO. Google and Microsoft account sign-in work this way, as do many products with enterprise SSO.

That lookup endpoint is the sharpest edge in the design, because it can become an account directory for attackers. If an unknown email gets "no account" and a known one gets "enter password", a bot can test a million emails. Two rules fix it.

First, the response for an unknown email looks exactly like the response for a password account: show the password screen (or send a code) either way, and fail later with the same generic message. Second, only reveal things that are public anyway. An SSO redirect is decided by the email domain (@acme.com), not by whether the person exists, so it leaks nothing about the individual.

ts
type NextStep =
  | { step: "password"; pending: string }   // also returned for unknown emails
  | { step: "email-code"; pending: string } // a code is on its way
  | { step: "sso"; redirectUrl: string };   // decided by the email domain

async function lookup(email: string): Promise<NextStep> {
  const res = await fetch("/api/auth/lookup", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email }),
  });
  if (res.status === 429) throw new RateLimited(res.headers.get("Retry-After"));
  return res.json();
}
The lookup reply is a step, never a yes or no about the account.

Try each method in the lab below. Watch the requests, the number of steps, and what an attacker learns from the replies when the email is not registered.

Method
Errors

identifier-first, step 1 of 2: email. Request POST /api/auth/lookup, reply { step: "password" }. An attacker probing emails learns nothing.

Sign-in with identifier-first: 2 steps. identifier-first, step 1 of 2: email. Request POST /api/auth/lookup, reply { step: "password" }. An attacker probing emails learns nothing. A registered email gets: { step: "password" }, then 401. An unknown email gets: { step: "password" }, then 401.emailpasswordPOST /api/auth/lookupreply: { step: "password" }registered email:{ step: "password" }, then 401unknown email:{ step: "password" }, then 401attacker learns nothing
2 steps. Press Next step to walk through it, then switch the errors to specific and compare.

Email links have a failure that surprises teams in production. Many corporate email security gateways and link-protection services open the links in incoming mail to scan them before anyone clicks. If GET /auth/magic?token=abc signs the person in and marks the token used, the scanner uses it first, and the real person gets "link expired".

The fix: the link opens a page with a Continue button, and only the POST from that button redeems the token. A second fix is to send a 6-digit code next to the link, so someone reading mail on a phone can type the code on the laptop where they started.

Check your understanding · Exercise 2

An identifier-first sign-in asks for the email, then calls POST /auth/lookup. Which replies are safe? Select all that apply.

Forms that work with password managers and people

Most sign-in friction is not the method, it is the form. A password manager that does not offer to fill, a code that must be typed digit by digit, a rule like "must contain a symbol" that rejects a 30-character passphrase: each one costs sign-ins and sends people to the reset flow. The fixes are mostly HTML attributes that cost nothing.

Tell the browser what each field is

The autocomplete attribute is how browsers and password managers know what to fill. Without it they guess from names and labels, and they guess wrong on custom designs. Four tokens matter here: username on the email field, current-password on sign-in, new-password on sign-up and reset (which also tells the manager to offer a generated password), and one-time-code on code fields (which lets iOS and macOS offer the code from a text message or email).

html
<form method="post" action="/auth/password">
  <label for="email">Email</label>
  <input id="email" name="email" type="email"
         autocomplete="username webauthn" required>

  <label for="pw">Password</label>
  <input id="pw" name="password" type="password"
         autocomplete="current-password" required>
  <button type="button" aria-pressed="false"
          aria-controls="pw">Show password</button>

  <button type="submit">Sign in</button>
</form>

<!-- sign-up and reset -->
<input type="password" autocomplete="new-password"
       aria-describedby="pw-hint">
<!-- verification code -->
<input inputmode="numeric" autocomplete="one-time-code"
       maxlength="6" pattern="[0-9]{6}">
Each field names itself. The form is a real form, so Enter submits and managers detect it.
  • Keep a hidden username on the password step. In identifier-first, the email was on screen one. Put it in a read-only or visually hidden autocomplete="username" field on the password screen too, or managers save the password with no username attached.
  • Never block paste. Blocking paste breaks people who copy from a password vault, and NIST SP 800-63B says verifiers must allow password managers and should allow paste. It does nothing against bots, which do not paste.
  • Offer a show-password toggle. A real button that switches the input type between password and text, with aria-pressed. It replaces the old "confirm password" field: one field you can check beats two you type blind.
  • Do not split the email from the domain or auto-capitalize it. Add autocapitalize="none" and spellcheck="false" on the email field.

Password rules: length over complexity

The old rules (one upper case, one digit, one symbol, change every 90 days) made passwords like Summer2025! that are easy to guess and hard to remember. NIST SP 800-63B Revision 4 (the US government's authentication guideline, final in 2025) turns that around. Verifiers must not require mixes of character types and must not force periodic changes.

A password used alone must be at least 15 characters; one used with a second factor at least 8. Verifiers must allow password managers and autofill, and should allow paste, accept at least 64 characters, and accept every printable character including spaces. And they must reject passwords found on a blocklist of common, expected or breached passwords.

RuleOld practiceNIST SP 800-63B Rev. 4
Minimum length815 if the password is the only factor, 8 with MFA
Maximum lengthOften 16 or 20Should accept at least 64
Character classesUpper, lower, digit, symbolMust not be required
RotationEvery 60 to 90 daysOnly after evidence of compromise
Breached or common passwordsRarely checkedMust be rejected
Paste and managersSometimes blockedManagers must be allowed; paste should be
Hints and security questionsCommonNot allowed

Checking breached passwords without sending them

How do you check a new password against hundreds of millions of breached ones without sending it anywhere? The Have I Been Pwned range API uses k-anonymity: you hash the password with SHA-1, send only the first 5 hex characters, and get back every breached hash suffix that starts with that prefix, with a count for each. Today that is roughly 2,000 lines per prefix. You compare the rest of the hash locally.

The service never learns the password or even its full hash. The check can run in the browser for instant feedback while typing, but the server must repeat it on submit, because the browser check can be skipped.

Breach check with k-anonymity, using the password P@ssw0rd. Its SHA-1 hash is split into a 5-character prefix, 21BD1, which is sent, and a 35-character suffix starting 2DC183F7, which stays in the browser. The browser calls the Have I Been Pwned range API with the prefix. The API returns about 2,000 suffixes with counts, including padding lines with a count of 0. One returned suffix matches ours, so the password has been breached and is rejected.SHA-1 of the password21BD12DC183F7...A757kept in the browsersends 5 of 40HIBP range APIGET /range/21BD10018A45C...: 42DC183F7...A757: many2E2A9B1D...: 0 (padding)match foundreject this password
The service sees only 21BD1, a prefix shared by about 2,000 breached hashes. The match happens in the browser, and Add-Padding: true hides even the size of the answer.
ts
async function timesBreached(password: string): Promise<number> {
  const bytes = new TextEncoder().encode(password);
  const digest = await crypto.subtle.digest("SHA-1", bytes);
  const hex = [...new Uint8Array(digest)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("")
    .toUpperCase();
  const prefix = hex.slice(0, 5);
  const suffix = hex.slice(5);
  const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`, {
    headers: { "Add-Padding": "true" },
  });
  for (const line of (await res.text()).split("\n")) {
    const [s, count] = line.trim().split(":");
    if (s === suffix) return Number(count);
  }
  return 0;
}
The browser sends 5 of 40 hex characters. The match happens locally.

Add-Padding: true asks the API to add a random number of fake entries to every response, so someone watching the encrypted traffic cannot guess the prefix from the response size. Padded lines have a count of 0, so a real match always has a count above 0. Debounce the check by about 400 ms while typing, and show the result as guidance, not a red error, until the person submits.

Check your understanding · Exercise 3

A team wants to improve their sign-up form. Which changes help? Select all that apply.

Where the session lives in the browser

After any sign-in method succeeds, the server hands the browser a session: proof that later requests come from the same signed-in person. The naive SPA stores an access token, often a JWT, in localStorage and adds it as an Authorization: Bearer header on every fetch. It survives reloads and works across tabs, which is why so many tutorials do it.

It breaks on the first XSS bug. Any script running on your origin, including a compromised analytics package or one unescaped comment, can run localStorage.getItem("token") and send the token to its own server. The attacker now uses the account from their own machine, for as long as the token lives, even after the bug is fixed. The xss and auth-tokens lessons cover the mechanics; here we decide what this app does.

Decision Where should the SPA keep the session?

Token in localStorage

Store the access token (and often a refresh token) in localStorage; attach it as a bearer header.

Pros

  • Simple; survives reloads; shared by tabs.
  • No CSRF, since nothing is sent automatically.

Cons

  • Any XSS can read and steal it, and use it elsewhere.
  • Cannot be revoked quickly if it is a long-lived JWT.

Access token in memory, refresh token in an HttpOnly cookie

Keep a short access token (5 to 15 minutes) in a JavaScript variable.

On reload or expiry, call /auth/refresh; the refresh cookie is sent automatically and rotated each time.

Pros

  • XSS cannot steal the long-lived secret.
  • Works with APIs that expect bearer tokens.

Cons

  • XSS can still read the in-memory token while the page is open.
  • Each new tab and reload costs a refresh call before the first API call.
  • Refresh calls from several tabs can race.

HttpOnly session cookie, same site

The server sets __Host-session with HttpOnly; Secure; SameSite=Lax.

The browser sends it on every request to our API; JavaScript never sees it.

Pros

  • No secret reachable by any script.
  • Reloads and tabs just work; server can revoke a session instantly.

Cons

  • Cookies are sent automatically, so writes need CSRF defenses.
  • API must be same-site, or CORS with credentials.

Backend for frontend (BFF)

A thin server on the app's own origin does the OAuth dance and keeps provider tokens server-side.

The browser only holds an HttpOnly session cookie for the BFF, which proxies API calls.

Pros

  • RFC 10017, the IETF's best current practice for browser-based OAuth apps (August 2026), strongly recommends it for business apps and apps that handle personal data.
  • Provider tokens never reach the browser.

Cons

  • One more service to run and scale.
  • Every API call takes an extra hop through the proxy.

What we pick: Use an HttpOnly, Secure, SameSite=Lax session cookie set by a BFF on the app's own origin. It removes the stealable secret from JavaScript, makes reloads and tabs trivial, and lets the server revoke sessions. We pay for it with CSRF defenses and one small proxy service, both cheaper than an account takeover.

The lab below runs the four choices against the events that matter: an injected script, a forged cross-site form, a reload, a second tab, and a sign-out in another tab. Notice that no choice survives XSS completely. Even with an HttpOnly cookie, an injected script can send requests from inside the page while it is open. The difference is that it cannot take the session away and keep using it later.

Session in
Run

localStorage token selected. Run an event.

Session kept in localStorage token. Events run: none. localStorage token selected. Run an event.XSSCSRFreload2nd tabsign-outPick an event to run against this storage.localStorage token
0 of 0 events run are fully safe. Green is safe, hatched needs extra work, red is a stolen session. No choice is fully safe against XSS: the difference is whether the session can leave the browser.
http
HTTP/1.1 200 OK
Set-Cookie: __Host-session=s_9fK2...; Path=/; Secure; HttpOnly;
            SameSite=Lax; Max-Age=1209600
Cache-Control: no-store
Set by the BFF after any successful sign-in.
AttributeWhat it doesWhy we want it
HttpOnlyHides the cookie from document.cookieXSS cannot read or copy the session
SecureSent only over HTTPSNo leaks on a coffee-shop network
SameSite=LaxNot sent on cross-site subrequests or cross-site POSTBlocks most CSRF; still sent when following a link from email
__Host- prefixBrowser rejects it unless Secure, Path=/ and no DomainA sibling subdomain cannot set or overwrite it. RFC 10017 suggests the newer __Host-Http- prefix, which also blocks cookies set by script, but Safari does not support it yet
Max-Age=120960014 daysPaired with a server-side idle timeout and an absolute limit

SameSite=Lax is not a full CSRF defense. It treats blog.example.com and app.example.com as the same site, so a bug on any subdomain can still send forged requests. Add one more check on every state-changing request: the BFF rejects writes whose Origin header is not our app, or requires a CSRF token header that only our own JavaScript can add (the csrf lesson compares these). Do it on the sign-in endpoint too: login CSRF signs a victim into the attacker's account, so anything they save lands where the attacker can read it.

Why Lax and not Strict? RFC 10017 and the auth-tokens lesson suggest SameSite=Strict for a BFF cookie, and it is stronger. But Strict leaves the cookie off the first request when someone follows a link from Slack or email, and our BFF reads the cookie when it serves the HTML (the redirects chapter), so a signed-in person would land on sign-in. We keep Lax and rely on the Origin check for writes; an app that only reads the session from fetch calls after the page loads can use Strict.

How data flows through the BFF

Data flow through the BFF. In the browser, Components send a mutation to the API client and read data from the Cache; the API client writes JSON replies into the Cache. A note says no token lives in JavaScript and the cookie is HttpOnly. The API client sends requests with the cookie to the BFF on the same origin. The BFF looks up the session by cookie ID in the Sessions store, calls the Product API with a bearer token, and talks to Google with a code and PKCE verifier only during sign-in.BrowserComponentsforms, listsCacheby query keyAPI clientadds CSRFmutatereadno token in JavaScriptcookie is HttpOnlyBFFsame origincookieSessionsby cookie IDAPIbearer tokenGooglecode + PKCE
The only thing that crosses from the browser is the request plus a cookie the page cannot read. The BFF turns it into a session lookup and an upstream bearer token, so no token ever exists in JavaScript.

Putting the BFF at app.example.com/api instead of calling api.example.com directly also removes CORS from the picture: every call is same-origin, so there are no preflight requests and no Access-Control-Allow-Credentials to get wrong. The BFF adds the upstream bearer token when it forwards each call. The cors lesson explains what we avoid.

Check your understanding · Exercise 4

The app uses an HttpOnly, Secure, SameSite=Lax session cookie. Which statements are true? Select all that apply.

Social sign-in with OAuth and OpenID Connect

"Continue with Google" uses two standards stacked together. OAuth 2.0 is a way for one site to get permission from another. OpenID Connect (OIDC) adds identity on top: an ID token, a signed JWT that says who signed in, with claims like sub (a stable user ID), email and email_verified. The flow every current guide recommends for browsers is the authorization code flow with PKCE.

The naive version is the old implicit flow: the provider puts the token straight into the URL fragment and the SPA reads it. Tokens in URLs end up in browser history and logs, and any script on the page can grab them. RFC 9700, the OAuth security best current practice, says not to use the implicit flow, and RFC 10017 says browser apps must not. With our BFF, the browser never sees a provider token at all.

The authorization code flow, step by step

Sequence diagram of Google sign-in between the Browser, the BFF and Google. The browser asks the BFF to start. The BFF replies with a 302 carrying state and the PKCE challenge. The browser goes to Google's authorize page with the challenge. Google redirects back with a code and the state. The browser sends them to the BFF callback. The BFF sends the code and the verifier to Google. Google replies with an ID token. The BFF replies with Set-Cookie and a redirect to the return path.BrowserBFFGoogleGET .../google/start302: state, challengeauthorize + challenge302 callback?code&stateGET callback, check statecode + verifierID token, check nonceSet-Cookie, 302 returnTo
The browser only ever carries a one-time code and the state. The verifier and the ID token stay between the BFF and Google, and the reply sets our own first-party cookie.
  1. The person presses "Continue with Google". The SPA navigates the whole page to /api/auth/google/start?returnTo=/projects/42 on our BFF.
  2. The BFF makes three random values: state (ties the reply to this browser), nonce (ties the ID token to this sign-in), and a PKCE code_verifier (43 to 128 characters). It stores them in a short-lived, HttpOnly cookie or server-side, keyed to this browser, along with the validated return path.
  3. It redirects to Google with response_type=code, client_id, redirect_uri, scope=openid email profile, state, nonce, and code_challenge (the SHA-256 of the verifier, base64url encoded) with code_challenge_method=S256.
  4. The person approves at Google. Google redirects to /api/auth/google/callback?code=...&state=....
  5. The BFF checks that state matches what it stored. If not, it stops: someone may be pushing their own code into our browser (a login CSRF).
  6. It posts the code and the code_verifier to Google's token endpoint with its client secret, and gets back an ID token. It verifies the signature, iss, aud, expiry and nonce.
  7. It finds or creates the account by sub, sets our own session cookie, and redirects to the saved return path.

PKCE (Proof Key for Code Exchange) protects step 6. If an attacker steals the code from a log or a malicious extension, they still cannot trade it, because only our BFF knows the verifier whose hash went out in step 3.

Match accounts by sub, not by email. The sub claim is stable for one provider (iss), while emails can change at the provider. Linking a Google identity to an existing password account just because the email matches lets anyone who controls a matching Google account take over. Link only after the person signs in to the existing account, or after the provider marks the email verified and you have decided to trust that provider for that domain.

Decision Should social sign-in use a full-page redirect or a popup?

Full-page redirect

The whole tab goes to the provider and comes back to the callback URL.

Pros

  • Works in every browser, including in-app browsers and strict privacy modes.
  • No popup blockers, no cross-window messaging.

Cons

  • The SPA unloads, so in-memory state is lost unless saved first.
  • Feels heavier on desktop.

Popup window

Open the provider in a small window from the click handler; the callback page sends the result back with postMessage and closes.

Pros

  • The app stays loaded, so unsaved work stays in memory.
  • Feels quick on desktop.

Cons

  • Blocked if not opened directly from a click.
  • Breaks in many in-app browsers and on some mobile browsers.
  • A strict Cross-Origin-Opener-Policy cuts the link back to the opener; you must check the message origin.

Browser-native (FedCM)

The browser shows its own account chooser using navigator.credentials.get with an identity option; no redirect, no third-party cookies.

Pros

  • Smoothest UI; designed for a web without third-party cookies.
  • Google's sign-in library already uses it in Chrome.

Cons

  • Chromium browsers only as of 2026; Safari and Firefox need a fallback.
  • Only for providers that support it.

What we pick: Default to the full-page redirect, because it works everywhere, and save any unsaved state before leaving (the redirects chapter shows how). Let the provider's own library use FedCM or One Tap where the browser supports it, as a progressive extra. Use a popup only for re-linking an account from settings, where the page has a lot of state and the person is on desktop.

Why third-party cookies matter here

Older SPA libraries kept a session alive with a hidden iframe to the provider ("silent refresh"). That iframe needs the provider's cookie in a third-party context. Safari blocks third-party cookies by default and Firefox partitions them per site, so silent refresh fails there and people get signed out at random.

Chrome decided in 2025 to keep third-party cookies, but you cannot design for one browser. The BFF avoids the whole problem: our session is a first-party cookie, and the provider is only involved at the moment of sign-in.

Check your understanding · Exercise 5

Which checks must the callback handler do before creating a session? Select all that apply.

Passkeys: sign-in without a shared secret

A passkey is a WebAuthn credential: a key pair the device creates for one website. The private key never leaves the authenticator, the thing that holds it. That is either a passkey provider on the device, often synced across the person's devices (iCloud Keychain, Google Password Manager, Windows Hello, or a password manager like 1Password), or a roaming authenticator such as a USB or NFC security key.

The server stores only the public key. To sign in, the server sends a random challenge, the authenticator signs it after a fingerprint, face or PIN check, and the server verifies the signature.

Two facts make passkeys the strongest method we offer. A leak of our database gives the attacker only public keys, which sign nothing. And the browser binds each passkey to a relying party ID (our domain, example.com), so a lookalike page at examp1e.com cannot ask for it. Phishing, the attack that defeats passwords and SMS codes, does not work.

Creating a passkey

Offer creation at moments of success, not as a wall at sign-up: right after a password or email-code sign-in ("Sign in faster next time?"), and in security settings. The server generates the options; the frontend passes them to navigator.credentials.create and posts the result back.

ts
async function addPasskey() {
  const json = await api.post("/api/auth/passkeys/options");
  const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(json);
  // publicKey.rp.id = "example.com", user.id = opaque bytes, not the email
  // authenticatorSelection: { residentKey: "required", userVerification: "preferred" }
  // excludeCredentials lists passkeys this account already has
  try {
    const cred = await navigator.credentials.create({ publicKey });
    await api.post("/api/auth/passkeys", (cred as PublicKeyCredential).toJSON());
    toast("Passkey added");
  } catch (err) {
    if (err instanceof DOMException && err.name === "NotAllowedError") return; // cancelled
    if (err instanceof DOMException && err.name === "InvalidStateError") {
      toast("This device already has a passkey for your account");
      return;
    }
    throw err;
  }
}
Registration. Options come from the server as JSON; the challenge is single use.

residentKey: "required" makes it a discoverable credential: the authenticator remembers which account it belongs to, so sign-in needs no username at all. NotAllowedError usually means the person closed the prompt; treat it as a cancel, not an error.

The JSON helpers (parseCreationOptionsFromJSON, toJSON) are recent additions to WebAuthn, shipped in Chrome 129, Firefox 119 and Safari 18.4. Feature-detect them and fall back to a small library such as SimpleWebAuthn that converts base64url strings to ArrayBuffers for you.

Signing in with passkey autofill

The naive passkey sign-in is a "Sign in with a passkey" button. People without one press it and get a confusing browser dialog about security keys and QR codes. Conditional UI fixes this: on page load, the app starts a passkey request with mediation: "conditional", and nothing appears yet.

When the person focuses the email field marked autocomplete="username webauthn", the browser lists their passkeys for our site in the normal autofill menu, next to saved passwords. Picking one finishes sign-in. People without a passkey see an ordinary email field.

Sequence diagram of passkey sign-in with autofill, between the Person, the Browser, the page's JavaScript and the BFF. On load, the page gets a challenge from the BFF and receives options. The page calls credentials.get with conditional mediation; nothing shows yet. The person focuses the email field, and the browser's autofill lists their passkeys. The person picks one and confirms with Face ID. The browser returns a signed assertion to the page, which sends it to the BFF to verify. The BFF replies with Set-Cookie.PersonBrowserPage JSBFFget challengeoptionsget(conditional)focus email fieldautofill: passkeyspick, Face IDsigned assertionverifySet-Cookie
The request is pending from page load, but invisible. It only completes if the person picks a passkey from autofill, so people without one just see a normal email field. Abort it with an AbortController before any other WebAuthn call.
ts
const abort = new AbortController();

async function canUsePasskeyAutofill(): Promise<boolean> {
  if (typeof PublicKeyCredential === "undefined") return false;
  if (PublicKeyCredential.getClientCapabilities) {
    const caps = await PublicKeyCredential.getClientCapabilities();
    return caps.conditionalGet === true;
  }
  return (await PublicKeyCredential.isConditionalMediationAvailable?.()) ?? false;
}

async function startPasskeyAutofill() {
  if (!(await canUsePasskeyAutofill())) return;
  const json = await api.post("/api/auth/passkeys/challenge");
  const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(json);
  try {
    const cred = await navigator.credentials.get({
      publicKey, mediation: "conditional", signal: abort.signal,
    });
    const next = await api.post("/api/auth/passkeys/verify", (cred as PublicKeyCredential).toJSON());
    router.finishSignIn(next); // a full session, or the next step
  } catch (err) {
    if ((err as DOMException).name !== "AbortError") showGenericError();
  }
}

// Before any other WebAuthn call (for example a security-key MFA step):
// abort.abort();
Started on page load; resolves only if the person picks a passkey from autofill.

Only one WebAuthn request can run at a time, so abort the conditional request before starting another one, such as a modal passkey prompt or a security-key MFA step. Keep the challenge short-lived (about 5 minutes) and refresh it if the page stays open longer.

The feature check prefers PublicKeyCredential.getClientCapabilities(), which reports what the browser supports in one call (Chrome 133, Firefox 135, Safari 17.4), and falls back to the older isConditionalMediationAvailable().

Fallback paths

  • No passkey on this device, but one on a phone. The browser's own dialog offers a QR code; the phone signs over a Bluetooth proximity check. We do nothing extra, but we should not hide the email path behind it.
  • Passkey deleted on the server but still on the device, for example removed from another device's settings. The person picks it from autofill and the server rejects it. Show the generic error and the email path, and where supported (Chrome and Safari, not yet Firefox) call PublicKeyCredential.signalUnknownCredential so the password manager hides it. In settings, list passkeys with the device name and last-used date so people can clean up.
  • Lost every device. Recovery goes through the email flow plus whatever the account requires. Passkeys do not remove the need for account recovery; they move it.
  • Old browser or OS. PublicKeyCredential is missing or conditional mediation is unavailable. The page is a normal email form; no error shown.

Check your understanding · Exercise 6

Which statements about passkeys are correct? Select all that apply.

MFA, step-up and remembered devices

Multi-factor authentication asks for a second proof of a different kind: something you have (a phone app, a security key) on top of something you know (a password). Passkeys already combine both (the device plus a fingerprint or PIN), so accounts that sign in with a passkey usually skip this step. For everyone else, some workspaces require it.

The naive design treats MFA as a modal on top of the signed-in app: create the session, then ask for the code. That is backwards. If the session exists before the second factor, an attacker with only the password already has a cookie that works on some endpoints.

The server must issue a pending state after the first factor, a short-lived token that can only call the MFA endpoints, and the full session only after the second. The frontend just follows the steps the server returns, which is why we model sign-in as a state machine.

The sign-in state machine. Identifier goes to Method after the lookup, to SSO for a company domain, or straight to Signed in when a passkey is picked from autofill. Method, a password or email code, goes to MFA when the first factor is right and MFA is required, or straight to Signed in when no MFA is needed. MFA goes to Signed in when the second factor is right. Method goes to Rate limited on a 429, and back to Method when the wait ends. A wrong answer stays in the same state with the same generic error.identifierSSOmethodpassword, coderate limitedRetry-Aftermfapending tokensigned inlookupdomainfirst factor oksecond factor okno MFApasskey429waitwrong answer:same error
The server picks every arrow: each reply is the next step. MFA is a state with its own pending token, not a dialog on top of a session, so skipping it gets an attacker nothing.

Decision Which second factors should we offer?

Authenticator app (TOTP)

At setup, show a QR code with a shared secret; the app shows a 6-digit code that changes every 30 seconds (RFC 6238).

The server accepts the current code and usually one step either side for clock drift.

Pros

  • Free, offline, works on any phone.
  • No carrier, no SMS costs.

Cons

  • Phishable: a fake page can relay the code within 30 seconds.
  • People lose the phone; needs recovery codes.

SMS code

Send a 6-digit code by text; the person types it or the browser fills it.

Pros

  • Everyone has it; no setup app.

Cons

  • SIM swap and phishing attacks; NIST lists phone-based codes as a restricted authenticator.
  • Costs money per message and attracts SMS pumping fraud.

Security key or passkey (WebAuthn)

Same WebAuthn get call as passkeys, with allowCredentials listing this account's keys.

Pros

  • Phishing-resistant.
  • One touch.

Cons

  • Hardware keys cost money and can be lost; people need two.

What we pick: Offer WebAuthn (passkey or security key) first and TOTP second, with 10 single-use recovery codes shown at setup. Allow SMS only as a fallback for accounts that cannot use the others, and never as the only recovery path for an admin.

Entering codes without friction

Use one input, not six boxes. A single field with autocomplete="one-time-code", inputmode="numeric" and maxlength="6" gets autofill from Messages on iOS and macOS, paste of the whole code, and one clear label for screen readers. Six separate boxes need custom code for paste, backspace, autofill and focus, and each box announces as a separate unlabelled field unless built with great care (the otp-input problem covers that design). Submit automatically when the sixth digit arrives, but keep a visible Verify button.

In Chromium browsers, the WebOTP API can read the code from an SMS for the person: Chrome on Android reads it on the phone, and desktop Chrome can receive it from the same person's Android phone. The text must end with a line naming our origin, like @app.example.com #482913, and the page calls navigator.credentials.get({ otp: { transport: ["sms"] } }) after asking for the SMS.

Chrome shows a consent sheet, then returns the code. Safari and Firefox do not support it, so feature-detect with "OTPCredential" in window and keep the normal field.

Step-up for sensitive actions

A session that is 13 days old should not be enough to change the account email, add a passkey, export all data or delete a workspace. Those actions need a fresh authentication, for example within the last 10 minutes. The server records when each factor was last proved.

When a request needs freshness it does not have, the server answers 401 with { "error": "reauth_required", "maxAge": 600 }. The frontend opens a re-auth dialog (passkey, password, or code), then retries the original request. The person stays on the page, and the form they were filling keeps its values.

Remember this device

Asking for a code on every sign-in from the same laptop trains people to approve codes without thinking. A "Remember this device for 30 days" checkbox sets a separate long-lived, HttpOnly device cookie holding a random ID the server links to the account. When it is present and valid, the server skips the second factor (never the first). The device appears in security settings with its browser, rough location and last use, and is revoked when the password changes, when MFA is reset, or on "sign out everywhere".

Check your understanding · Exercise 7

Which designs are correct for MFA? Select all that apply.

Errors, enumeration and abuse

Error messages on most pages should be as specific as possible. On sign-in pages, specific errors help attackers. Account enumeration means learning which emails have accounts.

It sounds harmless, but a list of confirmed accounts makes credential stuffing and targeted phishing much cheaper ("Your Example account has a problem, click here"). The goal is that every screen gives the same answer for an existing and a missing account, in text, status code, body shape and timing. The OWASP Authentication Cheat Sheet gives the same advice, with the same kind of generic messages.

ScreenLeaky messageSafe messageHow the real owner still gets helped
Sign in"No account with that email""Email or password is incorrect"A visible Forgot password link and a Create account link on the same screen
Forgot password"We don't recognise that email""If an account exists for ana@example.com, we sent a link. It expires in 30 minutes."The email itself says what to do, including "you don't have an account, sign up here"
Sign up"That email is already registered""Check your inbox to continue"The email to an existing account says "you already have an account, sign in here"
Email code"Code sent" only for real accountsSame screen and timing for every emailUnknown emails get a sign-up email instead of a code

Timing leaks too. If the server returns early for an unknown email but runs a 250 ms Argon2id check for a real one, a script can tell them apart. The server should run the same hash work in both paths, and send email from a background queue so "email sent" and "no account" take the same time. The frontend's part: never branch the UI on anything the server did not deliberately send.

Rate limits shown humanely

The server limits attempts per account, per IP and per device, and NIST SP 800-63B caps consecutive failed attempts with one authenticator on one account at 100. A hard lockout after 5 failures sounds safe, but it lets anyone lock the CEO out by typing their email wrong five times. Prefer growing delays (for example 1, 2, 4, 8 seconds, then 15 minutes) plus a challenge when signals look risky, and email the owner about repeated failures.

The frontend's job is to make limits understandable. A 429 Too Many Requests with Retry-After: 300 becomes "Too many attempts. You can try again in 5 minutes, or reset your password now." with a live countdown, the button disabled until it ends, and the reset link still active. Never show a raw 429 or a spinner that never ends. Disable the submit button while a request is in flight, so double clicks do not burn two attempts.

CAPTCHA only when risky

A CAPTCHA on every sign-in costs every real person time and fails many disabled people, and modern solving services beat image puzzles for a fraction of a cent each. Show a challenge only when the server's risk score says so: many failures, a known-bad IP range, a new device for a high-value account. Prefer low-friction checks such as Cloudflare Turnstile or reCAPTCHA v3, which usually need no puzzle.

Load the challenge script only when a challenge is needed, so the sign-in page stays fast and works when the vendor is down. WCAG 3.3.8 allows object recognition tests ("pick the photos with a bus") at level AA, but transcribing distorted text is a cognitive function test, so always keep an accessible alternative.

Password reset and account recovery

  • The reset link carries a single-use token with a short life (30 to 60 minutes). Only a POST from the reset page redeems it, for the same link-scanner reason as magic links.
  • Keep the token out of logs and referrers. Send Referrer-Policy: no-referrer on the reset page and remove the token from the URL with history.replaceState once it is read, so a third-party script or a shared screenshot does not leak it.
  • After a reset, sign out every other session and revoke remembered devices, then sign this browser in. Tell the person in the UI and by email.
  • Resetting a password does not bypass MFA. If the account has a second factor, the reset flow asks for it, or for a recovery code. Losing both goes to a slower, human-reviewed recovery with a waiting period, because that path is what account takeover crews aim at.
  • No security questions. "Mother's maiden name" is public on social media, and NIST no longer allows them.

Check your understanding · Exercise 8

Which choices avoid leaking whether an account exists? Select all that apply.

Redirects back and protected routes

Someone opens a link from Slack to app.example.com/projects/42/tasks/7 while signed out. They should land on sign-in and, after it, on task 7, not on the dashboard. The naive way is /sign-in?returnTo= plus whatever URL they came from, and after sign-in location.href = returnTo.

That is an open redirect: a phisher sends app.example.com/sign-in?returnTo=https://examp1e.com/billing, the victim signs in on our real page, and lands on a fake one that asks for their card "again". Since the link started on our domain, people and spam filters trust it.

Validating the return path

Accept only same-origin paths, and check them by parsing, not with string tricks. //evil.test and /\evil.test both start with a slash but browsers treat them as other hosts. The BFF repeats the same check, because it is the one that stores returnTo across the OAuth round trip.

ts
const FALLBACK = "/home";

export function safeReturnTo(raw: string | null): string {
  if (!raw) return FALLBACK;
  try {
    const url = new URL(raw, window.location.origin);
    if (url.origin !== window.location.origin) return FALLBACK;
    if (url.pathname.startsWith("/sign-in")) return FALLBACK; // no loops
    return url.pathname + url.search + url.hash;
  } catch {
    return FALLBACK;
  }
}
Parse against our origin; keep only the path, query and hash.

Keeping state across the trip

The URL carries where to go. It cannot carry what the person was doing: a half-written comment, filters they set, a cart. Before a full-page redirect to sign-in or to Google, save that draft to sessionStorage (per tab, cleared when the tab closes) or to the app's existing draft store, keyed by route and user ID.

After sign-in, the page restores it. Never save secrets or another user's data there, and drop drafts if a different account signs in than the one that wrote them.

Auth state without flashes

A common SPA bug: the app boots, does not know the session yet, renders the sign-in page for 300 ms, then the /api/session call returns and it swaps to the dashboard. Or the opposite: a protected page flashes its layout before kicking you out. The fix is a third state. Auth is unknown until the session check returns, and while unknown the app shows a neutral shell (the logo and a skeleton), never the sign-in form and never private data.

What a signed-in person sees while the app boots, at first paint, when JavaScript is ready, and when the session is known. Naive: blank, then the sign-in form by mistake, then the dashboard. Three states: a neutral shell, a shell, then the dashboard. BFF inline bootstrap: the dashboard from first paint.first paintJS readysession knownnaiveblanksign-in formdashboard3 statesshellshelldashboardBFF inlinedashboarddashboarddashboardred: the wrong screen, shown for a moment
The naive app guesses signed-out while it waits and flashes the wrong screen. An unknown state shows a neutral shell instead. When the BFF reads the cookie and inlines the user, there is nothing to wait for.

Better still, avoid the round trip. The BFF already sees the session cookie when it serves the HTML. For app routes it can redirect a signed-out request to /sign-in?returnTo=... with a 302 before any JavaScript runs, and for signed-in requests inline a tiny bootstrap object (user ID, name, workspace) into the page. The client route guard then only handles changes after boot.

Check your understanding · Exercise 9

Which returnTo values should safeReturnTo turn into the fallback? Select all that apply.

When the session ends in the middle of work

Sessions end for many reasons: the 14-day limit passes, an idle timeout fires, the person signs out in another tab, an admin revokes access, or they press "sign out everywhere" on their phone. The naive SPA handles a 401 by sending the whole page to /sign-in. Someone who spent 20 minutes on a long reply loses it. Worse, five requests fail at once and the app redirects five times.

A re-login dialog that keeps the work

Put the handling in one place: the API client. When any request gets 401 with { "error": "session_expired" }, the client pauses new requests, opens a single re-login dialog over the current page, and queues the failed request. After the person signs in again (passkey, password, or code; the BFF sets a new cookie), it replays the queue. If they cancel, or sign in as a different user, it drops the queue and reloads so no data from one account shows under another.

Sequence diagram of a session expiring during a save, between the Editor, the API client, the Re-login dialog and the BFF. The editor asks to save a comment. The API client posts it with idempotency key k1. The BFF replies 401 session expired. The API client opens the re-login dialog once. The dialog re-authenticates with the BFF, which replies with a new cookie. The dialog resolves, the API client replays the post with the same key k1, the BFF replies 201 saved, and the editor shows saved with the text kept.EditorAPI clientRe-loginBFFsave commentPOST, key k1401 expiredopen oncere-authenticatenew cookieresolvedreplay, key k1201 savedsaved, text kept
The failure never reaches the editor: the API client turns the 401 into a dialog and a replay. The same Idempotency-Key makes the replay safe even if the first post had been applied.
ts
let reauth: Promise<void> | null = null;

export async function apiFetch(input: string, init: RequestInit = {}) {
  const isWrite = (init.method ?? "GET") !== "GET";
  const key = isWrite ? crypto.randomUUID() : null; // one key per logical write
  const send = () => fetch(input, { ...init, credentials: "same-origin",
    headers: { ...(init.headers as Record<string, string>), "X-CSRF": csrfToken(),
      ...(key ? { "Idempotency-Key": key } : {}) } });
  let res = await send();
  if (res.status !== 401) return res;
  const body = await res.clone().json().catch(() => ({}));
  if (body.error === "reauth_required" || body.error === "session_expired") {
    reauth ??= openReloginDialog(body).finally(() => { reauth = null; });
    await reauth;                 // throws if the person cancels
    res = await send();           // same key, so a repeat is not applied twice
  }
  return res;
}
One dialog for any number of failed requests. Only safe to replay if the server dedupes writes.

Replaying a POST is only safe if the server can recognize a repeat. Send an Idempotency-Key header with every write, so a request that actually succeeded before the 401 on its reply is not applied twice. The unsaved form itself stays in component state, because nothing unmounted: the dialog sits on top.

Signing out in every tab

Sign out in one tab and the server deletes the session, but the other four tabs still show private data until their next request. Tell them right away with a BroadcastChannel (the cross-tab-sync lesson compares it with storage events). Every tab listens on a channel named auth.

On signed-out, a tab clears its query cache and in-memory stores and shows the signed-out screen. On signed-in with a different user ID, it reloads. Messages carry no secrets, only the event and the user ID.

Three tabs of the app. In tab A the person presses Sign out, which posts to the server and sends a signed-out event on the BroadcastChannel named auth. Tabs B and C receive it, clear their caches, and show the signed-out screen.tab ASign outPOST sign-outtab Bsigned outcache clearedtab Csigned outdrafts droppedBroadcastChannel("auth")signed-outevent and user ID only, never a secret
The message carries only the event and the user ID. Each tab clears its own memory, because no tab can reach into another's.

Sign out, and sign out everywhere

  1. POST /api/auth/sign-out (a POST with the CSRF check, so a hidden image tag cannot sign people out). The BFF deletes the session server-side and clears the cookie with Max-Age=0.
  2. The client clears every place user data lives: the query cache, stores, sessionStorage drafts, IndexedDB databases, and service worker caches with personal responses.
  3. It broadcasts signed-out to other tabs and navigates with location.replace("/sign-in"), so Back does not show the old page. Private pages are served with Cache-Control: no-store so they stay out of the HTTP cache, and a pageshow listener re-checks the session when event.persisted says the page came back from the back-forward cache.
  4. Sign out everywhere in settings revokes every session and remembered device for the account, except optionally the current one. Other devices learn at their next request (a 401), or at once if the app holds a WebSocket, where the server can push a session_revoked event.

Check your understanding · Exercise 10

A person's session expires while they type a long comment. Three API calls fail with 401 at the same moment. What should happen? Select all that apply.

Architecture, data model and API

With the decisions made, the pieces fall into three layers. The auth pages are a small, separate bundle: sign-in, sign-up, reset, MFA, and the OAuth landing pages. They load fast and import nothing from the main app.

The app shell holds the session state, the API client with the re-login logic, and the tab channel. The BFF on the same origin owns every secret: it sets cookies, talks to Google, verifies WebAuthn, and proxies the API.

Architecture. Inside the browser, on the left: Auth pages, App screens, Session store and Tab channel. On the right: the Sign-in machine, the WebAuthn helper, and the API client. Auth pages drive the Sign-in machine, which uses the WebAuthn helper. App screens read the Session store and call the API client, which updates the Session store. The Session store tells other tabs through the Tab channel. The Sign-in machine and the API client talk to the BFF on the same origin, sending steps and the session cookie. The BFF talks to Google with PKCE and calls the Product API with a bearer token.Browser, app.example.comAuth pagesidentifier, MFAApp screensroutes, guardsSession storeunknown, in, outTab channelBroadcastChannelSign-in machinestep, pendingWebAuthnone requestAPI clientCSRF, 401 queueGoogleOIDC providerBFFsessions, OAuthProduct APIbearer upstreamstepscookiePKCEbearer
Every secret lives right of the browser box. The browser holds only state about the session; the BFF holds the cookie's meaning, the Google tokens and the upstream bearer token.
PartResponsibilityWhy the boundary is here
Auth pages bundleIdentifier, method, MFA, reset screens; the sign-in state machineUnder 80 KB so the sign-in page is fast, and a smaller attack surface: no third-party widgets from the app
Sign-in state machineHolds the current step and the pending token; moves on server repliesThe server decides the next step; the UI only renders it, so new methods add states, not branches
WebAuthn helperConditional UI, create, get, abortOne place owns the single WebAuthn request the browser allows
Session storeunknown, signed-in or signed-out plus the user summaryEvery screen reads it; nothing else decides if someone is signed in
API clientCSRF header, 401 handling, re-login queue, idempotency keysOne choke point, so a session ending is handled once, not in 200 components
Tab channelBroadcastChannel("auth") eventsKeeps tabs in step without any of them reading a secret
BFFSessions, cookies, OAuth with PKCE, WebAuthn verify, rate limits, API proxySecrets stay server-side, and every call is same-origin: no CORS

Why keep the auth pages out of the main bundle? Two reasons. A signed-out visitor should not download the whole app (often several hundred KB) to see a form with two fields. And the sign-in page is where a malicious script does the most harm, since it sees passwords as they are typed.

The fewer scripts the sign-in page loads, the fewer can be compromised. That is also why it gets the strictest Content Security Policy in the product: only our own scripts, plus the identity provider's origin for the Google button.

Check your understanding · Exercise 11

Why does the 401 and re-login handling live in the API client rather than in each page? Select all that apply.

What the browser holds

The most important line in the data model is a negative one: no secret is a client entity. The browser never holds a password after submit, a refresh token, a provider token or a session ID it can read. What it holds is a description of the session and of where the person is in the sign-in flow.

ts
type Session =
  | { status: "unknown" }
  | { status: "signed-out"; reason?: "expired" | "revoked" | "signed-out" }
  | { status: "signed-in"; user: { id: string; name: string; avatarUrl?: string };
      workspaceId: string; authLevel: "single" | "mfa"; authTime: number };

type SignInStep =
  | { step: "identifier" }
  | { step: "password" | "email-code"; email: string; pending: string }
  | { step: "mfa"; methods: ("webauthn" | "totp" | "sms" | "recovery")[]; pending: string }
  | { step: "sso"; redirectUrl: string }
  | { step: "done"; returnTo: string };

type DeviceSession = {              // security settings list
  id: string; current: boolean; browser: string; os: string;
  approxLocation?: string; lastSeenAt: string; rememberedForMfa: boolean;
};
Client-side entities. Secrets appear only as cookies the client cannot read.
The data model in two zones. JavaScript can read: SignInStep with the step and pending token, Session with status and user, a list of DeviceSession entries for settings, and Drafts in sessionStorage. A note says no passwords after submit and no tokens or session IDs. HttpOnly or server only: the __Host-session cookie for 14 days, the __Host-device cookie for 30 days, and the provider tokens, which stay on the BFF. The session cookie fills the Session summary through the BFF.JavaScript can readSignInStepstep, pendingSessionstatus, userDeviceSessionsettings listDraftssessionStorageno passwords after submitno tokens, no session IDHttpOnly or server__Host-session14 days__Host-device30 daysprovider tokens
The line between the zones is the security design. Everything on the left is safe to lose to an XSS bug for the life of the tab; everything on the right is a secret, and no script can read it.
DataWhere it livesLifetime
Session cookie__Host-session, HttpOnly14 days, idle timeout on the server
Device cookie__Host-device, HttpOnly30 days, revoked with sessions
Session summarySession store in memory, filled from the HTML bootstrap or /api/sessionPage lifetime
SignInStep and pendingState machine in memory; pending is an opaque step token, not a sessionAbout 10 minutes
Return pathURL query, and server-side during OAuthOne trip
Unsaved draftssessionStorage, per tab, keyed by route and user IDUntil restored or the tab closes

The pending token deserves a word. After a correct password on an MFA account, the server returns a token that proves "first factor done for this account, 10 minutes ago" and nothing more. It lives only in memory. If the person reloads mid-MFA they start again, which is the right trade: storing it would turn a half sign-in into something an XSS bug could steal.

The API between the pages and the BFF

http
POST /api/auth/lookup           { email }            -> SignInStep
POST /api/auth/password         { pending, password } -> SignInStep
POST /api/auth/email-code       { pending, code }    -> SignInStep
POST /api/auth/mfa              { pending, method, response } -> SignInStep
POST /api/auth/passkeys/challenge                   -> request options JSON
POST /api/auth/passkeys/verify  { credential }       -> SignInStep
POST /api/auth/passkeys/options                     -> creation options JSON
POST /api/auth/passkeys         { credential }       -> 201, needs fresh auth
GET  /api/auth/google/start?returnTo=/projects/42    -> 302 to Google
GET  /api/auth/google/callback?code&state            -> 302 to returnTo
POST /api/auth/reset/request    { email }            -> 202 always
POST /api/auth/reset/confirm    { token, password }  -> SignInStep
POST /api/auth/sign-out                              -> 204
POST /api/auth/sessions/revoke-all                   -> 204
GET  /api/session                                    -> Session

401 { "error": "invalid_credentials" }          same for unknown email
401 { "error": "session_expired" }
401 { "error": "reauth_required", "maxAge": 600 }
429 { "error": "rate_limited" }  Retry-After: 300
Every reply to a step is the next SignInStep, or an error with a stable code.

Errors use a short list of stable codes, and the frontend maps each code to text. Never show the server's free-text message: it is a common source of accidental leaks ("user ana@example.com not found") and it cannot be translated. The reset request always answers 202 Accepted, whatever the email.

Check your understanding · Exercise 12

Which of these may the browser keep in JavaScript-readable storage? Select all that apply.

Accessibility, speed and monitoring

A sign-in page that a screen reader user cannot finish locks them out of the whole product, so accessibility here is not polish, it is availability. The good news is that the choices made so far (real forms, one code field, passkeys, password manager support) already do most of the work.

Accessible authentication

  • Visible labels, not placeholders. Placeholders vanish when typing and are often too low in contrast. Each field has a label; hints sit under it and link with aria-describedby.
  • Errors are announced and linked. On a failed submit, set aria-invalid="true" on the field, link the message with aria-describedby, and move focus to the first invalid field, or to an error summary at the top for a form-level error like "Email or password is incorrect". The screen-readers lesson covers how live regions and focus moves are read.
  • Each step moves focus to its heading. When the identifier screen becomes the password screen, focus the new h1 ("Enter your password") so screen reader users hear that the page changed.
  • WCAG 2.2 success criterion 3.3.8 (level AA) says no step may need a cognitive function test, such as remembering a password or transcribing a code, unless there is an alternative method or a mechanism that helps. Allowing paste and password managers, offering passkeys, and using autocomplete="one-time-code" are exactly those mechanisms. Level AA also exempts tests that ask people to recognize common objects or content they gave the site themselves, such as their own photo. Blocking paste on a code field, with no other method offered, fails it.
  • Time limits are generous and recoverable. A code that expires in 10 minutes has a visible Resend link; a countdown is not announced every second.

Speed

The sign-in page is a landing page: people arrive from email, bookmarks and ads. Server-render it, keep its JavaScript under 80 KB gzipped, and keep the LCP element (usually the heading) as plain server-rendered text so it paints in under 1.5 seconds on a mid-range phone. Load the Google button script with async and reserve its space so it does not shift the layout. The moment the person submits a credential, prefetch the app shell bundle, so the page after sign-in is already in the cache when the session cookie arrives.

Monitoring without leaking

Track the funnel per step and per method: identifier shown, method chosen, MFA shown, signed in, with drop-off and time for each. If the passkey path converts better than the password path, that number is the argument for prompting more people to add a passkey. Watch error codes over time: a jump in invalid_credentials or 429 from many IPs is a credential stuffing wave, often before any other alert.

And scrub aggressively. Error trackers and real-user monitoring (RUM) tools record URLs, so strip code, state and reset token parameters before they are sent, and never log form values on auth pages.

Check your understanding · Exercise 13

Which designs meet WCAG 2.2 success criterion 3.3.8 Accessible Authentication (Minimum)? Select all that apply.

Running it in the interview

Auth is a feature question where interviewers listen for judgment, not for every RFC. In 45 minutes you cannot cover everything in this guide. Pick the spine (methods, session, errors, session end) and show depth on one or two flows the interviewer cares about.

MinutesWhat to doWhat to draw
0 to 5Ask the questions: methods, SSO, subdomains, MFA policy, mobile appsA list of assumptions
5 to 12Methods and identifier-first; the no-enumeration ruleThe two screens and the lookup reply
12 to 22Session: cookie versus tokens, then the BFF, CSRF, SameSiteBrowser, BFF and API boxes with the cookie
22 to 32One deep flow: Google with PKCE, or passkeys with conditional UIA sequence diagram
32 to 40MFA as states, 401 mid-session, multi-tab sign-out, redirectsThe state machine
40 to 45Accessibility, monitoring, and what you would do nextNothing new; point at the drawing

Say the trade-offs out loud. "I'll keep the session in an HttpOnly cookie. That means I need CSRF protection, so here is how." "The error text will be generic, which costs some honest users a moment, so I'll put the reset link right there." Interviewers remember candidates who name what their choice costs.

Think first

The interviewer asks: "Why not just put a JWT in localStorage? Everyone does it." What do you answer?

Think first

Follow-up: "A user signs in with Google, but they already have a password account with the same email. What happens?"

  • "How do you protect against credential stuffing?" Rate limits per account, IP and device; breached-password checks; risk-based challenges; MFA and passkeys; alerts on error-code spikes.
  • "How does sign out everywhere reach a laptop that is asleep?" The server revokes the sessions; the laptop's next request gets 401, and a WebSocket push covers open tabs immediately.
  • "What changes for a mobile web view?" Google blocks OAuth sign-in in embedded web views, so open the system browser or an in-app browser tab, and keep the redirect flow.

Check your understanding · Exercise 14

You have 10 minutes left and have not discussed the session. What is the best use of the time? Pick one.

Summary

  • Ask for the email first and let the server answer with the next step: password, email code or SSO. The answer must look the same for accounts that do not exist, and passkeys come from autofill with no lookup at all.
  • Build forms for password managers: autocomplete tokens (username, current-password, new-password, one-time-code), paste allowed, a show-password toggle, length over complexity, and a k-anonymity breach check.
  • Keep the session in an HttpOnly, Secure, SameSite=Lax __Host- cookie set by a same-origin BFF. No token in localStorage. Pay for it with Origin or CSRF-token checks on every write, including sign-in.
  • Social sign-in is the authorization code flow with PKCE, state and nonce, done by the BFF with a full-page redirect. Match accounts by sub, never by email alone.
  • Passkeys are the phishing-resistant goal: conditional UI on the username field, offered after a successful sign-in, with the email path always available as a fallback.
  • MFA is a state in the sign-in machine with a pending token, not a modal on top of a full session. Step-up asks again in place before sensitive actions.
  • Validate every return path by parsing it against your own origin, and handle a 401 with one re-login dialog that keeps the page and replays requests with idempotency keys.
  • Sign out clears every client cache and tells every tab over BroadcastChannel; the server is always the real guard.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 4

References

Concepts behind it

Try next