Frontend Labs
⋯

67. Authentication in the browser: sessions, tokens, OAuth and passkeys

Sessions or tokens? Where do credentials live?

HttpOnly session cookie Bearer token in localStorage Token in memory + BFF
Readable by XSS No Yes: exfiltrated and usable from anywhere Not the refresh token
CSRF exposure Yes, so use SameSite + CSRF token No Cookie to the BFF, mitigated the same way
Works across subdomains / APIs Needs same-site or a proxy Easy BFF proxies the APIs
Revocation Delete the server session Wait for expiry, or keep a denylist Server-side
Recommended for first-party web apps not recommended for sensitive apps SPAs using OAuth/OIDC

The IETF's guidance for browser-based OAuth apps recommends the Backend-for-Frontend (BFF) pattern. A small server you control does the OAuth dance, keeps the tokens, and gives the browser only an HttpOnly session cookie. Support Desk is the simple version of this: the browser never holds a token, only a cookie that JavaScript cannot read.

Hardened session cookies

// Support Desk (dev); in production add Secure and use the __Host- prefix:
// Set-Cookie: __Host-sid=…; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=1800
function sessionCookie(sid, maxAgeSec) {
  return [`sid=${sid}`, "HttpOnly", "SameSite=Lax", "Path=/", `Max-Age=${maxAgeSec}`, PROD && "Secure"]
    .filter(Boolean).join("; ");
}
const sid = randomBytes(32).toString("base64url");   // a NEW id at every login: no session fixation
  • HttpOnly: invisible to document.cookie, so XSS can't steal it. The Playwright suite asserts this (§77).
  • __Host- prefix: the browser only accepts the cookie if it is Secure, has Path=/, and has no Domain. A sibling subdomain can't overwrite it.
  • SameSite=Lax: not sent on cross-site POSTs, which blocks most CSRF. Strict is stronger, but users arriving from a link appear logged out on that first navigation.
  • Timeouts. Use idle expiry on the server (Support Desk slides 30 minutes), plus an absolute maximum lifetime. The UI warns before the idle timeout, and Playwright tests that warning with a fake clock (§77).

CSRF: three layers

function requireCsrf(req, session) {
  // 1. Fetch Metadata: browsers label every request's relationship to the page
  const site = req.headers["sec-fetch-site"];
  if (site && site !== "same-origin" && site !== "none") throw new HttpError(403, "Cross-site request blocked");
  // 2. A per-session token that a cross-site form cannot know, compared in constant time
  const a = Buffer.from(String(req.headers["x-csrf-token"] ?? ""));
  const b = Buffer.from(session.csrf);
  if (a.length !== b.length || !timingSafeEqual(a, b)) throw new HttpError(403, "Missing or invalid CSRF token");
}
// 3. SameSite=Lax on the session cookie (above)

The client gets the token from /api/me and sends it in a custom header on every state-changing request (api.js). A cross-site attacker can't set custom headers without a CORS preflight, which your server won't approve.

OAuth 2.1 / OpenID Connect with PKCE

When users sign in with an identity provider (Google, Microsoft, Okta, Auth0, Keycloak), use the authorization code flow with PKCE. PKCE binds the code to the app instance that started the login, so a stolen authorization code is useless:

const b64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

async function startLogin() {
  const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)));     // 43 chars
  const challenge = b64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))));
  const state = crypto.randomUUID();                                       // ties the callback to this tab (CSRF)
  sessionStorage.setItem("oauth", JSON.stringify({ verifier, state }));
  location.assign(`${ISSUER}/authorize?` + new URLSearchParams({
    response_type: "code", client_id: CLIENT_ID, redirect_uri: `${location.origin}/callback`,
    scope: "openid profile email", state, code_challenge: challenge, code_challenge_method: "S256",
  }));
}

async function handleCallback() {
  const params = new URLSearchParams(location.search);
  const { verifier, state } = JSON.parse(sessionStorage.getItem("oauth") ?? "{}");
  sessionStorage.removeItem("oauth");
  if (!state || params.get("state") !== state) throw new Error("state mismatch");   // forged callback
  history.replaceState(null, "", "/");                                            // drop ?code from the URL bar/history
  // In the BFF pattern this POST goes to YOUR server, which adds the client secret and keeps the tokens.
  const res = await fetch("/bff/token", {
    method: "POST", headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ code: params.get("code"), verifier }),
  });
  if (!res.ok) throw new Error("login failed");
}

Don't hand-write the server side (ID token signature validation, nonce, issuer and audience checks). Use a certified library such as openid-client or your framework's auth module. The implicit flow (response_type=token) is removed in OAuth 2.1. Do not use it.

Passkeys (WebAuthn): phishing-resistant sign-in

A passkey is a key pair. The private key never leaves the user's device or password manager. The server stores the public key and verifies a signature over a fresh challenge. The browser includes the page's origin in what gets signed, so a phishing site on a look-alike domain gets a signature the real server rejects. There is no shared secret to steal.

The JSON helpers make the client side short. This flow was run in Chromium with Playwright's virtual authenticator (§77):

// Registration: the server sends PublicKeyCredentialCreationOptions as JSON
const options = await (await fetch("/webauthn/register/options", { method: "POST" })).json();
// e.g. { challenge, rp: { name: "Support Desk", id: "app.example" },
//        user: { id, name: "alice@example.com", displayName: "Alice" },
//        pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }],
//        authenticatorSelection: { residentKey: "required", userVerification: "required" } }
const credential = await navigator.credentials.create({
  publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options),
});
await fetch("/webauthn/register/verify", {            // the server verifies and stores the public key
  method: "POST", headers: { "Content-Type": "application/json" },
  body: JSON.stringify(credential.toJSON()),          // { id, type, response: { attestationObject, clientDataJSON, … } }
});

// Sign-in, with passkey autofill: <input name="username" autocomplete="username webauthn">
const requestOptions = await (await fetch("/webauthn/login/options", { method: "POST" })).json();
const assertion = await navigator.credentials.get({
  publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(requestOptions),
  mediation: "conditional",                           // passkeys appear in the username field's autofill menu
});
await fetch("/webauthn/login/verify", { method: "POST", body: JSON.stringify(assertion.toJSON()) });

In the test run, the decoded clientDataJSON of the sign-in contained type: "webauthn.get", origin: "https://example.com" and the server's challenge. That origin binding is what makes passkeys resistant to phishing. On the server, verify with a maintained library such as SimpleWebAuthn: it checks the challenge, origin, RP ID hash, signature and counter.

Sessions across tabs

// Log out every tab at once (and re-check auth when another tab logs in)
const auth = new BroadcastChannel("auth");
auth.onmessage = (e) => { if (e.data === "logout") location.assign("/login"); };
async function logout() {
  await api("/api/logout", { method: "POST" });
  auth.postMessage("logout");
}

War story: silent refresh after third-party cookie restrictions. For years SPAs refreshed tokens with a hidden iframe to the identity provider, which depended on the provider's cookie being sent in a third-party context. Safari's ITP and Firefox's Total Cookie Protection broke that pattern, and Chrome's long-planned third-party cookie phase-out threatened to do the same. Apps "randomly" logged users out after an hour. The durable fix was the BFF pattern (or refresh token rotation where a BFF is impossible). That is part of why the IETF guidance now leads with it.

Generated from real-life-JS-guide.md · 8 Oct 2026 16:36