Skip to content

Authentication & Passkeys

Authentication in a Progressive Web App uses the web platform's credential APIs: WebAuthn passkeys for phishing-resistant sign-in, the Credential Management API for passwords and account selection, FedCM for federated sign-in without third-party cookies, WebOTP for SMS codes and the Digital Credentials API for identity documents. What makes PWAs different is where they run: an installed app window has no address bar, may keep its cookies apart from the browser (iOS and macOS Safari do), and treats a redirect to an identity provider as an out-of-scope navigation that each platform handles differently. This page covers each API down to its options, errors and browser support, shows complete client and server code for passkeys with SimpleWebAuthn, and explains how to keep sessions, OAuth flows and offline access working in installed apps.

Key takeaways

  • Passkeys (discoverable WebAuthn credentials) work in every current engine and in installed PWAs. Make them the primary sign-in and use conditional mediation (mediation: "conditional" plus autocomplete="username webauthn") so they appear in autofill.
  • WebAuthn Level 3 became a W3C Recommendation on 25 August 2026. Its newer APIs are widely shipped: JSON helpers (parseCreationOptionsFromJSON, toJSON), getClientCapabilities(), conditional create, Related Origin Requests, and the Signal API (Chrome and Safari).
  • The server must verify the challenge, origin, RP ID hash, flags and signature. SimpleWebAuthn 14 (@simplewebauthn/server and @simplewebauthn/browser) handles this. Store the credential ID, public key, counter, transports and backup flags.
  • Storage differs per platform. Chromium installed apps share cookies with the browser profile. iOS Home Screen apps and macOS Safari web apps get their own cookie jar. Safari copies cookies when the app is created (macOS 17.0, iOS 17.2), and after that the two sessions diverge.
  • OAuth redirects leave your app's scope. Desktop Chromium keeps them in the window with an origin bar, iOS opens them in Safari View Controller, and macOS Safari may open the default browser. Use a server-side flow, keep redirect_uri in scope, and use window.open() popups where the platform needs them.
  • Keep tokens out of JavaScript-readable storage. Use __Host- HttpOnly cookies (a backend-for-frontend), clear caches on sign-out, and design offline mode around a local lock (for example WebAuthn PRF) rather than offline "login".

The authentication toolbox

API What it does Where it works (September 2026) Use it for
WebAuthn / passkeys (navigator.credentials.create/get({publicKey})) Public-key credentials bound to your RP ID, synced by the platform or kept on a security key Chrome, Edge, Safari, Firefox; desktop and mobile Primary sign-in, step-up, payment confirmation
Conditional mediation (passkey autofill) Shows passkeys in the username field's autofill Chrome 108, Safari 16, Firefox 119 Username-less sign-in without a modal
Credential Management: PasswordCredential, FederatedCredential Programmatic access to the browser's saved passwords and account choices Chromium only Auto sign-in for returning users on Chromium
FedCM (navigator.credentials.get({identity})) Browser-mediated federated sign-in without third-party cookies or redirects Chromium (Chrome 108+); Firefox has only the Login Status API "Sign in with IdP" in Chromium, especially in app windows
WebOTP (navigator.credentials.get({otp})) Reads an origin-bound SMS one-time code Chrome on Android 84+, desktop cross-device 93+ Phone verification, SMS second factor
Digital Credentials (navigator.credentials.get({digital})) Requests identity documents from a wallet Chrome 141+, Safari 26+ Age and identity verification, not sign-in
OAuth 2.0 / OpenID Connect Redirect- or popup-based delegation to an identity provider Everywhere Enterprise SSO, social sign-in outside Chromium

The rest of this page follows that order, then covers the parts that are specific to installed apps: session storage, redirects in standalone mode, token storage and offline access.

How passkeys work

A passkey is a WebAuthn discoverable credential: a key pair created by an authenticator, whose private key never leaves the authenticator (or the platform's end-to-end encrypted sync). The public key is stored on your server. Every sign-in is a signature over a fresh server challenge.

  • Relying party (RP) and RP ID. Your site is the relying party. Each credential is bound to an RP ID, a domain that must equal the page's effective domain or be a registrable suffix of it: login.example.com can use example.com, but not example.org or com. IP addresses aren't valid effective domains, while localhost is. Choose the RP ID once, carefully: credentials can't be moved to another RP ID.
  • Authenticators. A platform authenticator is built into the device (Touch ID, Face ID, Windows Hello, Android screen lock) and usually backed by a passkey provider such as iCloud Keychain, Google Password Manager or a third-party password manager. A roaming authenticator is a security key over USB, NFC or BLE. The hybrid transport lets a phone act as the authenticator for a nearby computer through a QR code and a Bluetooth proximity check.
  • Discoverable vs non-discoverable. A discoverable credential stores the user handle on the authenticator, so the browser can list accounts without your server naming credentials first. That's what enables username-less sign-in and autofill.
  • User presence (UP) and user verification (UV). UP proves someone touched or approved the prompt. UV proves who: a biometric or a device PIN, checked locally. Biometric data never reaches your server.
  • Synced vs device-bound. Most platform passkeys are synced across the user's devices (the authenticator reports backup eligible and backed up flags). Security keys and some enterprise-managed credentials are device-bound.

Phishing resistance comes from two bindings the browser enforces: the browser writes the calling origin into clientDataJSON, which the authenticator signs, and the authenticator writes the SHA-256 hash of the RP ID into the signed authenticatorData. A lookalike domain gets neither the right RP ID nor the right origin, so its assertion fails server-side verification even if the user is fooled.

sequenceDiagram
    participant U as User
    participant P as PWA page
    participant B as Browser / OS
    participant A as Authenticator
    participant S as Your server
    Note over P,S: Registration
    P->>S: POST /webauthn/register/options
    S-->>P: creation options (challenge, rp, user, excludeCredentials)
    P->>B: navigator.credentials.create({publicKey})
    B->>U: create a passkey? (biometric or PIN)
    B->>A: makeCredential(rpIdHash, clientDataHash)
    A-->>B: attestationObject (authData with new public key)
    B-->>P: PublicKeyCredential
    P->>S: POST /webauthn/register/verify (JSON)
    S->>S: verify challenge, origin, rpIdHash, flags, store public key
    Note over P,S: Authentication
    P->>S: POST /webauthn/login/options
    S-->>P: request options (challenge, rpId)
    P->>B: navigator.credentials.get({publicKey})
    B->>A: getAssertion
    A-->>B: authenticatorData, signature, userHandle
    B-->>P: PublicKeyCredential
    P->>S: POST /webauthn/login/verify
    S->>S: look up credential, verify signature, update counter, start session

Creating a passkey: navigator.credentials.create()

create-options.js
const credential = await navigator.credentials.create({
  publicKey: {
    rp: { id: "example.com", name: "Example" },
    user: {
      id: userHandleBytes,           // 1–64 random bytes; never an email or other PII
      name: "[email protected]",      // shown in account pickers
      displayName: "Jane Doe",
    },
    challenge: challengeBytes,       // ≥16 random bytes from the server, single-use
    pubKeyCredParams: [
      { type: "public-key", alg: -8 },   // EdDSA (Ed25519)
      { type: "public-key", alg: -7 },   // ES256
      { type: "public-key", alg: -257 }, // RS256 (Windows Hello, older keys)
    ],
    timeout: 300000,
    excludeCredentials: existingCredentials, // [{type: "public-key", id, transports}]
    authenticatorSelection: {
      residentKey: "required",       // a discoverable credential: a passkey
      userVerification: "preferred",
      // authenticatorAttachment: "platform" | "cross-platform" (omit to allow both)
    },
    hints: [],                       // "security-key" | "client-device" | "hybrid"
    attestation: "none",
    extensions: { credProps: true },
  },
  // mediation: "conditional",       // automatic passkey upgrade, see below
  // signal: abortController.signal,
});

Every creation option

Option Default What it does and the edge cases
rp.id The page's effective domain Must be the effective domain or a registrable suffix of it, unless Related Origin Requests allow it. Otherwise SecurityError
rp.name required Display name. Some UIs no longer show it, but it's required
user.id required The user handle, 1–64 bytes, or TypeError. Returned as userHandle on sign-in. Use random bytes stored on the user record. Registering a new credential with the same user.id and RP ID replaces the old one on most authenticators
user.name, user.displayName required Shown in account pickers. Can be updated later with signalCurrentUserDetails()
challenge required Random bytes the server generated and remembers. The spec asks for at least 16 bytes with 100 bits of entropy
pubKeyCredParams ES256 and RS256 if empty Ordered preference of COSE algorithms. If the authenticator supports none, NotSupportedError
timeout client default Milliseconds. The spec recommends 300,000–600,000 (5–10 minutes) and lets browsers clamp values
excludeCredentials [] Credentials the user already has. If the authenticator holds one, it returns InvalidStateError after user consent, so the user learns they're already registered
authenticatorSelection.authenticatorAttachment none "platform" or "cross-platform". Restricts choices; usually omitted in favor of hints
authenticatorSelection.residentKey "discouraged" (unless requireResidentKey) "discouraged", "preferred", "required". Use "required" for passkeys
authenticatorSelection.requireResidentKey false Legacy Level 1 boolean; set to true when residentKey is "required" for old browsers
authenticatorSelection.userVerification "preferred" "required", "preferred", "discouraged". With "preferred", check the UV flag on the server and decide
hints [] Ordered UI hints: "security-key", "client-device", "hybrid". Chrome 128+. Unlike authenticatorAttachment, hints don't restrict, they steer
attestation "none" "none", "indirect", "direct", "enterprise" (see Attestation below)
attestationFormats [] Preferred attestation statement formats. Not widely implemented
extensions none credProps (report whether the credential is discoverable), prf, largeBlob, credProtect, minPinLength, payment (Secure Payment Confirmation)

mediation sits next to publicKey in the outer CredentialCreationOptions, not inside it. signal takes an AbortSignal; aborting rejects the promise with the signal's reason (AbortError by default; Safari 27 fixed a bug where it always used a generic AbortError).

Errors from create()

Error Typical cause
NotAllowedError The user cancelled, the timeout expired, or the call lacked required user activation (Safari requires a user gesture for modal ceremonies; cross-origin iframes always do). Browsers deliberately report many failures this way to avoid leaking information
InvalidStateError An authenticator matched excludeCredentials: the user already has a passkey for this account on this provider
SecurityError The RP ID isn't valid for this origin, the page isn't a secure context, or a Related Origin Requests check failed
NotSupportedError No authenticator supports any algorithm in pubKeyCredParams, or an unknown type
TypeError Malformed options, such as a user.id longer than 64 bytes
AbortError Your AbortSignal fired, or another WebAuthn call replaced this one
ConstraintError A security key can't satisfy residentKey: "required" or userVerification: "required"

What comes back

create() resolves with a PublicKeyCredential whose response is an AuthenticatorAttestationResponse:

Member Content
id / rawId Credential ID, as base64url string and ArrayBuffer
authenticatorAttachment "platform" or "cross-platform" (Chrome 98, Firefox 120, Safari 15.5)
response.clientDataJSON UTF-8 JSON: {type: "webauthn.create", challenge, origin, crossOrigin, topOrigin?}
response.attestationObject CBOR: {fmt, attStmt, authData}
response.getAuthenticatorData() authData without parsing CBOR
response.getPublicKey() SubjectPublicKeyInfo DER, or null for algorithms the browser doesn't understand
response.getPublicKeyAlgorithm() COSE algorithm identifier
response.getTransports() ["internal", "hybrid", "usb", "nfc", "ble", "smart-card"] subset. Store it; pass it back in allowCredentials
getClientExtensionResults() e.g. {credProps: {rk: true}, prf: {enabled: true}}
toJSON() A RegistrationResponseJSON with every buffer base64url-encoded (Chrome 129, Firefox 119, Safari 18.4)

The authenticatorData layout

Both ceremonies return authenticatorData, a compact binary structure the authenticator signs. Libraries parse it for you, but knowing the layout makes debugging possible:

Bytes Field Notes
0–31 rpIdHash SHA-256 of the RP ID. Must match the expected RP ID on your server
32 flags Bit 0 UP (user present), bit 2 UV (user verified), bit 3 BE (backup eligible), bit 4 BS (backed up), bit 6 AT (attested credential data present), bit 7 ED (extension data present)
33–36 signCount Big-endian 32-bit counter. Synced passkeys usually report 0 forever
37+ (if AT) attested credential data 16-byte AAGUID (authenticator model or provider), 2-byte credential ID length, credential ID, COSE-encoded public key
rest (if ED) extensions CBOR map of authenticator extension outputs

BE and BS let you tell users whether a passkey will survive losing the device: BE=1 means the provider syncs it, BS=1 means it is currently backed up. A device-bound credential (BE=0) is a good reason to encourage registering a second one.

Attestation: when you need it

Attestation proves which authenticator model created the credential. It's off by default ("none") because consumer sites rarely need it and it adds privacy cost:

  • "none": the browser may replace the statement with fmt: "none" and zero the AAGUID.
  • "indirect": the browser may anonymize the statement.
  • "direct": you get the authenticator's statement as generated. Browsers may show extra consent UI.
  • "enterprise": uniquely identifying attestation for managed devices; browsers allow it only for RP IDs the enterprise policy lists.

Formats include packed, tpm, android-key, android-safetynet, fido-u2f, apple and none. Synced passkey providers generally return no attestation statement, even when you ask for "direct", but identify themselves through the AAGUID; the community-maintained passkey authenticator AAGUID list maps AAGUIDs to provider names you can show in a "your passkeys" settings page. Require attestation only in regulated or enterprise deployments that must restrict authenticator models, and then validate it against the FIDO Metadata Service (SimpleWebAuthn's MetadataService does this).

Signing in: navigator.credentials.get()

get-options.js
const assertion = await navigator.credentials.get({
  publicKey: {
    challenge: challengeBytes,     // fresh, single-use, from the server
    rpId: "example.com",           // defaults to the effective domain
    allowCredentials: [],          // empty = discoverable credentials (username-less)
    userVerification: "preferred",
    timeout: 300000,
    hints: [],
    extensions: {},
  },
  mediation: "optional",           // "silent" | "optional" | "conditional" | "required"
  signal: abortController.signal,
});
  • allowCredentials lists {type: "public-key", id, transports} descriptors. Leave it empty for passkeys: the browser shows every discoverable credential for the RP ID. Fill it when the user has already typed a username (identifier-first flows) or for security keys with non-discoverable credentials; the transports you stored at registration help the browser skip irrelevant UI.
  • userVerification defaults to "preferred". For a passkey-only primary sign-in, "required" is reasonable. For a second factor after a password, "discouraged" avoids a redundant PIN prompt.
  • The response is an AuthenticatorAssertionResponse with clientDataJSON (type: "webauthn.get"), authenticatorData, signature and userHandle (the user.id from registration; always present for discoverable credentials). The server looks up the credential by id, checks that its owner's user handle matches userHandle, and verifies the signature over authenticatorData || SHA-256(clientDataJSON) with the stored public key.

Errors mirror create(): NotAllowedError for cancellation, timeout or no matching credential (by design, a page can't distinguish "user cancelled" from "no passkey"), SecurityError for RP ID problems, AbortError from your signal.

Mediation modes

mediation belongs to the Credential Management API and applies to every credential type:

Value Behavior
"silent" Never show UI. Only returns a credential that can be released without interaction (auto sign-in with a stored password in Chromium). WebAuthn assertions need interaction, so they return null
"optional" (default) Show UI if needed
"conditional" Don't show a modal; offer credentials through autofill on a suitable input. The promise stays pending until the user picks one or you abort
"required" Always show UI, even if a credential could be released silently. Use after sign-out

Immediate UI mode: sign in only if a credential exists

Chromium-only

Immediate UI mode shipped in Chrome 148 on desktop and Android, after an origin trial in Chrome 139–141. As of mid-2026 Chrome is the only browser that supports it, and WebKit's recorded standards position is negative.

The origin trial used mediation: "immediate"; the shipped API is a separate uiMode: "immediate" member of the get() options, and mediation: "immediate" no longer triggers it. When the user clicks Sign in or Checkout, the browser shows an account picker only if it already holds a passkey (or, with password: true, a saved password) for your site. Otherwise the promise rejects straight away with NotAllowedError and you show your normal sign-in page, so users who have no credential never see an empty modal.

immediate-ui.js
signInButton.addEventListener("click", async () => {
  const caps = await PublicKeyCredential.getClientCapabilities?.();
  if (!caps?.immediateGet) return showSignInPage();
  try {
    const options = await fetch("/webauthn/login/options", { method: "POST" }).then((r) => r.json());
    const cred = await navigator.credentials.get({
      password: true,                                   // also offer saved passwords
      publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options), // allowCredentials must be empty
      uiMode: "immediate",
    });
    await finishLogin(cred); // a PublicKeyCredential or a PasswordCredential
  } catch (err) {
    // NotAllowedError: nothing known locally, user dismissed, or a private window.
    showSignInPage();
  }
});

Chrome's rules: the call must come from a user gesture (it doesn't consume the activation), a non-empty allowCredentials list rejects with NotAllowedError to prevent cross-session tracking, and requests in incognito or private windows always reject. Detect support through the immediateGet key of getClientCapabilities().

Conditional mediation: passkeys in autofill

Conditional mediation solves the bootstrapping problem: you can't show a "Sign in with a passkey" modal to users who may not have one, but you can quietly offer passkeys in the username field's autofill alongside saved passwords.

login.html
<form id="login" method="post" action="/login">
  <label for="username">Email</label>
  <!-- "webauthn" must be the last token; the field must exist before get() is called -->
  <input id="username" name="username" type="email"
         autocomplete="username webauthn" required>
  <label for="password">Password</label>
  <input id="password" name="password" type="password" autocomplete="current-password">
  <button type="submit">Sign in</button>
  <button type="button" id="passkey-button">Sign in with a passkey</button>
</form>
conditional-ui.js
let conditionalAbort = null;

async function startConditionalUI() {
  if (!window.PublicKeyCredential?.isConditionalMediationAvailable) return;
  if (!(await PublicKeyCredential.isConditionalMediationAvailable())) return;

  conditionalAbort = new AbortController();
  try {
    const options = await fetch("/webauthn/login/options", { method: "POST" }).then((r) => r.json());
    const credential = await navigator.credentials.get({
      publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options), // Chrome 129+, Safari 18.4+, Firefox 119+
      mediation: "conditional",
      signal: conditionalAbort.signal,
    });
    await finishLogin(credential);
  } catch (err) {
    // AbortError is expected when you switch to the modal flow or leave the page.
    if (err.name !== "AbortError") console.error("Conditional UI failed:", err);
  }
}

document.getElementById("passkey-button").addEventListener("click", async () => {
  // Only one WebAuthn request can be pending: cancel the conditional one first.
  conditionalAbort?.abort();
  try {
    const options = await fetch("/webauthn/login/options", { method: "POST" }).then((r) => r.json());
    const credential = await navigator.credentials.get({
      publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options),
    });
    await finishLogin(credential);
  } catch (err) {
    // NotAllowedError = cancelled, timed out or no passkey (indistinguishable by design).
    if (err.name !== "NotAllowedError") console.error("Passkey sign-in failed:", err);
    startConditionalUI(); // put passkeys back into autofill
  }
});

async function finishLogin(credential) {
  const res = await fetch("/webauthn/login/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(credential.toJSON()), // base64url-encodes every buffer
  });
  if (!res.ok) throw new Error(`Verification failed: ${res.status}`);
  location.assign("/");
}

startConditionalUI();

The details that trip people up:

  • The input needs autocomplete with a webauthn token (Safari 17.4 fixed its serialization), and must be in the DOM before get() runs. In an SPA, start conditional UI after rendering the sign-in route and abort it when the route unmounts.
  • allowCredentials must be empty: conditional requests only surface discoverable credentials.
  • Only one WebAuthn ceremony can be pending per page. Abort the conditional request before starting a modal one, then restart it if the user cancels the modal.
  • The challenge can go stale while the request sits in autofill for minutes. Issue challenges with an expiry that matches how long a sign-in page stays open, or re-fetch after aborting.
  • Android WebView reports isConditionalMediationAvailable() as false (MDN compatibility data). A Trusted Web Activity runs in Chrome and isn't affected.

Conditional create: automatic passkey upgrades

Conditional create lets you create a passkey silently right after a user signs in with a saved password: the password manager that autofilled the password creates a passkey without a prompt, if it decides the user consented earlier. Safari 18 shipped it, followed by Chrome 136 on desktop and Chrome 142 on Android. Pass mediation: "conditional" to create() right after a successful password sign-in, and expect it to fail quietly. Request userVerification: "preferred" (or "discouraged") in those options: with "required", the spec has the browser throw ConstraintError whenever it can't collect verification during this silent ceremony, which is the normal case. The spec requires the browser to create it without user presence or verification, so don't require UV or UP on the server for this one registration (SimpleWebAuthn: requireUserVerification: false, requireUserPresence: false, or useAutoRegister: true on the client helper).

auto-upgrade.js
async function tryPasskeyUpgrade() {
  const caps = await PublicKeyCredential.getClientCapabilities?.();
  if (!caps?.conditionalCreate) return;
  const options = await fetch("/webauthn/register/options?auto=1", { method: "POST" }).then((r) => r.json());
  try {
    const credential = await navigator.credentials.create({
      publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options),
      mediation: "conditional",
    });
    await fetch("/webauthn/register/verify?auto=1", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(credential.toJSON()),
    });
  } catch {
    // NotAllowedError / InvalidStateError: no upgrade this time. Never show an error.
  }
}

Feature detection with getClientCapabilities()

PublicKeyCredential.getClientCapabilities() (Chrome 133, Firefox 135, Safari 17.4) resolves with a record of booleans: conditionalCreate, conditionalGet, hybridTransport, immediateGet (Chrome 148), passkeyPlatformAuthenticator, userVerifyingPlatformAuthenticator, relatedOrigins, signalAllAcceptedCredentials, signalCurrentUserDetails, signalUnknownCredential, plus extension:<name> keys for supported extensions. A missing key means "unknown", not "unsupported". Older browsers only have isUserVerifyingPlatformAuthenticatorAvailable() and isConditionalMediationAvailable().

If you run example.com, example.co.uk and example-rewards.com, one RP ID can serve them all. Serve a JSON file at https://example.com/.well-known/webauthn:

.well-known/webauthn
{
  "origins": [
    "https://example.co.uk",
    "https://example.de",
    "https://example-rewards.com"
  ]
}

When example.co.uk calls WebAuthn with rp.id/rpId set to example.com, a supporting browser fetches the file (without credentials or referrer), and proceeds if the calling origin is listed. The spec requires browsers to support at least five distinct registrable origin labels (example, example-rewards count as two labels regardless of TLD). Chrome 128 and Safari 18 support it; Firefox doesn't yet, where the call throws SecurityError. Federation remains the better answer for many unrelated domains.

The Signal API: keeping providers in sync

When a user deletes a passkey on your settings page, the passkey provider doesn't know, and keeps offering it. The Signal API (Chrome 132, Safari 26; not in Firefox) lets you tell the provider:

signals.js
// After a sign-in attempt with a credential ID your server doesn't recognize:
await PublicKeyCredential.signalUnknownCredential({ rpId: "example.com", credentialId });

// After sign-in or on the settings page: the complete list of valid credential IDs.
await PublicKeyCredential.signalAllAcceptedCredentials({
  rpId: "example.com",
  userId: userHandleBase64url,
  allAcceptedCredentialIds: validIdsBase64url,
});

// After the user changes their email or display name.
await PublicKeyCredential.signalCurrentUserDetails({
  rpId: "example.com",
  userId: userHandleBase64url,
  name: "[email protected]",
  displayName: "Jane Doe",
});

Signals are best-effort, fire-and-forget: the promises resolve whether or not a provider acted. Arguments are base64url strings. Be careful with signalAllAcceptedCredentials(): omitting a valid credential ID can cause the provider to hide or delete it.

WebAuthn in iframes

navigator.credentials.get({publicKey}) in a cross-origin iframe requires the publickey-credentials-get Permissions Policy (allow="publickey-credentials-get"), and create() requires publickey-credentials-create. A cross-origin create() also requires transient activation and can't use conditional mediation. The resulting clientDataJSON has crossOrigin: true and a topOrigin, which your server must check against the embedders you allow.

Server verification with SimpleWebAuthn

SimpleWebAuthn is a widely used TypeScript/JavaScript implementation of WebAuthn for Node.js (20+), Deno, Bun and browsers. Version 14 is current: @simplewebauthn/server 14.0.3 and @simplewebauthn/browser 14.0.0 (September 2026). The server package generates options and verifies responses; the browser package wraps create()/get() with JSON conversion, conditional UI and error classification.

What the server must check on every ceremony, and what the library does for you:

  1. clientDataJSON.type is webauthn.create or webauthn.get (or payment.get for SPC).
  2. clientDataJSON.challenge equals the challenge you issued for this session, and you delete it after use (single-use, short-lived).
  3. clientDataJSON.origin is one of your origins, exactly (scheme, host, port). List every origin that legitimately calls WebAuthn, including related origins.
  4. rpIdHash equals SHA-256 of your RP ID.
  5. The UP flag is set; the UV flag is set if you require verification.
  6. The signature verifies with the stored public key (authentication), or the attestation statement is valid for its format (registration).
  7. signCount: if both stored and new values are non-zero and the new value isn't greater, the authenticator may be cloned. Synced passkeys report 0, so don't reject on 0.

Data model

Column Why
credential_id (base64url, unique) Look up the credential from assertion.id
user_id Owner. Compare with the user handle on sign-in
public_key (bytes) COSE public key for signature verification
counter (integer) Clone detection
transports (array) Pass back in allowCredentials and excludeCredentials
device_type (singleDevice / multiDevice) and backed_up UX: warn about unsynced credentials
aaguid, created_at, last_used_at, nickname Settings page: "iCloud Keychain, created March 3, last used today"

Store the random user_handle (the WebAuthn user.id) on the user row, separate from your internal primary key.

Complete Express server

webauthn-server.mjs
import express from "express";
import session from "express-session";
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from "@simplewebauthn/server";
import { isoBase64URL } from "@simplewebauthn/server/helpers";
import { db } from "./db.mjs"; // your data layer: users + credentials tables

const RP_ID = "example.com";
const RP_NAME = "Example";
// Every origin that may call WebAuthn with this RP ID (include related origins).
const EXPECTED_ORIGINS = ["https://example.com", "https://app.example.com"];
const CHALLENGE_TTL_MS = 5 * 60 * 1000;

const app = express();
app.set("trust proxy", 1);
app.use(express.json({ limit: "32kb" }));
app.use(session({
  name: "__Host-sid",               // __Host- prefix: Secure, Path=/, no Domain
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 30 * 24 * 3600 * 1000 },
}));
app.use("/webauthn", (req, res, next) => { res.set("Cache-Control", "no-store"); next(); });

function rememberChallenge(req, purpose, challenge) {
  req.session.webauthn = { purpose, challenge, expires: Date.now() + CHALLENGE_TTL_MS };
}

function takeChallenge(req, purpose) {
  const entry = req.session.webauthn;
  delete req.session.webauthn; // single use, even if verification fails
  if (!entry || entry.purpose !== purpose || entry.expires < Date.now()) return null;
  return entry.challenge;
}

// ---------- Registration (user must already be signed in, or be creating an account) ----------
app.post("/webauthn/register/options", async (req, res) => {
  const user = await db.users.findById(req.session.userId);
  if (!user) return res.status(401).json({ error: "sign in first" });
  const existing = await db.credentials.listByUser(user.id);

  const options = await generateRegistrationOptions({
    rpName: RP_NAME,
    rpID: RP_ID,
    userName: user.email,
    userDisplayName: user.displayName,
    userID: isoBase64URL.toBuffer(user.webauthnUserHandle), // random 32 bytes stored per user
    attestationType: "none",
    excludeCredentials: existing.map((c) => ({ id: c.credentialId, transports: c.transports })),
    authenticatorSelection: { residentKey: "required", userVerification: "preferred" },
  });

  rememberChallenge(req, req.query.auto ? "register-auto" : "register", options.challenge);
  res.json(options);
});

app.post("/webauthn/register/verify", async (req, res) => {
  const auto = Boolean(req.query.auto);
  const expectedChallenge = takeChallenge(req, auto ? "register-auto" : "register");
  if (!expectedChallenge || !req.session.userId) return res.status(400).json({ error: "expired" });

  let verification;
  try {
    verification = await verifyRegistrationResponse({
      response: req.body,
      expectedChallenge,
      expectedOrigin: EXPECTED_ORIGINS,
      expectedRPID: RP_ID,
      // Conditional create happens without UP/UV by design.
      requireUserPresence: !auto,
      requireUserVerification: false, // we asked for "preferred"; inspect the flag instead
    });
  } catch (err) {
    return res.status(400).json({ error: err.message });
  }
  if (!verification.verified) return res.status(400).json({ error: "not verified" });

  const { credential, credentialDeviceType, credentialBackedUp, aaguid, userVerified } =
    verification.registrationInfo;
  await db.credentials.insert({
    credentialId: credential.id,
    userId: req.session.userId,
    publicKey: Buffer.from(credential.publicKey),
    counter: credential.counter,
    transports: credential.transports ?? req.body.response?.transports ?? [],
    deviceType: credentialDeviceType,
    backedUp: credentialBackedUp,
    aaguid,
    userVerified,
    createdAt: new Date(),
  });
  res.json({ verified: true, backedUp: credentialBackedUp });
});

// ---------- Authentication (usernameless: no allowCredentials) ----------
app.post("/webauthn/login/options", async (req, res) => {
  const options = await generateAuthenticationOptions({
    rpID: RP_ID,
    userVerification: "preferred",
    allowCredentials: [],
  });
  rememberChallenge(req, "login", options.challenge);
  res.json(options);
});

app.post("/webauthn/login/verify", async (req, res) => {
  const expectedChallenge = takeChallenge(req, "login");
  if (!expectedChallenge) return res.status(400).json({ error: "expired" });

  const stored = await db.credentials.findById(req.body.id);
  if (!stored) {
    // Tell the client so it can call signalUnknownCredential().
    return res.status(404).json({ error: "unknown-credential", rpId: RP_ID, credentialId: req.body.id });
  }

  // The user handle in the assertion must belong to the credential's owner.
  const owner = await db.users.findById(stored.userId);
  if (req.body.response?.userHandle && req.body.response.userHandle !== owner.webauthnUserHandle) {
    return res.status(400).json({ error: "user handle mismatch" });
  }

  let verification;
  try {
    verification = await verifyAuthenticationResponse({
      response: req.body,
      expectedChallenge,
      expectedOrigin: EXPECTED_ORIGINS,
      expectedRPID: RP_ID,
      credential: {
        id: stored.credentialId,
        publicKey: new Uint8Array(stored.publicKey),
        counter: stored.counter,
        transports: stored.transports,
      },
      requireUserVerification: false,
    });
  } catch (err) {
    return res.status(400).json({ error: err.message });
  }
  if (!verification.verified) return res.status(401).json({ error: "not verified" });

  const info = verification.authenticationInfo;
  await db.credentials.update(stored.credentialId, {
    counter: info.newCounter,
    backedUp: info.credentialBackedUp,
    lastUsedAt: new Date(),
  });

  // Prevent session fixation: new session ID after authentication.
  req.session.regenerate((err) => {
    if (err) return res.status(500).json({ error: "session" });
    req.session.userId = owner.id;
    req.session.authMethod = info.userVerified ? "passkey-uv" : "passkey";
    req.session.authTime = Date.now();
    res.json({ verified: true });
  });
});

app.listen(3000);

Two library defaults matter: verifyRegistrationResponse() and verifyAuthenticationResponse() both default requireUserVerification to true in version 14. The example turns it off because the options ask for "preferred" and the session records whether UV happened; if you ask for "required", leave the default. generateRegistrationOptions() defaults supportedAlgorithmIDs to EdDSA, ES256 and RS256 (plus ML-DSA-44 when the runtime supports post-quantum algorithms); pass the same list to verification if you override it.

Browser side with @simplewebauthn/browser

passkeys-client.js
import {
  startRegistration,
  startAuthentication,
  browserSupportsWebAuthn,
  browserSupportsWebAuthnAutofill,
  WebAuthnError,
} from "@simplewebauthn/browser";

async function postJSON(url, body) {
  const res = await fetch(url, {
    method: "POST",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw Object.assign(new Error(data.error ?? res.statusText), { status: res.status, data });
  return data;
}

export async function registerPasskey() {
  const optionsJSON = await postJSON("/webauthn/register/options");
  try {
    const attResp = await startRegistration({ optionsJSON });
    return await postJSON("/webauthn/register/verify", attResp);
  } catch (err) {
    if (err instanceof WebAuthnError && err.code === "ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED") {
      return { verified: true, alreadyRegistered: true };
    }
    if (err.name === "NotAllowedError") return { verified: false, cancelled: true };
    throw err;
  }
}

export async function signIn({ autofill = false } = {}) {
  const optionsJSON = await postJSON("/webauthn/login/options");
  const asseResp = await startAuthentication({ optionsJSON, useBrowserAutofill: autofill });
  try {
    return await postJSON("/webauthn/login/verify", asseResp);
  } catch (err) {
    if (err.data?.error === "unknown-credential" && PublicKeyCredential.signalUnknownCredential) {
      // The passkey was deleted on the server: ask the provider to hide it.
      await PublicKeyCredential.signalUnknownCredential({
        rpId: err.data.rpId,
        credentialId: err.data.credentialId,
      }).catch(() => {});
    }
    throw err;
  }
}

export async function initSignInPage() {
  if (!browserSupportsWebAuthn()) return { passkeys: false };
  if (await browserSupportsWebAuthnAutofill()) {
    // Resolves only when the user picks a passkey from autofill.
    signIn({ autofill: true })
      .then(() => location.assign("/"))
      .catch((err) => { if (err.name !== "AbortError") console.warn(err); });
  }
  return { passkeys: true };
}

startAuthentication() cancels any pending conditional request itself when you call it again without autofill, through the library's shared abort service, which removes one of the common bugs in hand-written code.

Credential Management API: passwords and federated accounts

Chromium-only

PasswordCredential and FederatedCredential exist only in Chromium-based browsers (Chrome 51+, Edge 79+). Safari and Firefox support navigator.credentials for WebAuthn (and Safari for digital credentials) but not these two types. MDN marks both as experimental.

The Credential Management API gives JavaScript structured access to the browser's password manager. In an SPA or an installed PWA, where the form-submission heuristics password managers rely on don't always fire, it lets you store a credential after a successful sign-in and retrieve one for one-tap or automatic sign-in on the next visit.

password-credentials.js
// After a successful fetch()-based sign-in: ask the browser to save the password.
async function savePassword(form) {
  if (!("PasswordCredential" in window)) return;
  // The constructor reads fields by autocomplete: "username" → id, "current-password"/"new-password" → password.
  const cred = new PasswordCredential(form); // or new PasswordCredential({id, password, name, iconURL})
  await navigator.credentials.store(cred).catch(() => {});
}

// On app start: sign returning users in without a form.
async function autoSignIn() {
  if (!("PasswordCredential" in window)) return null;
  const cred = await navigator.credentials.get({
    password: true,
    mediation: "silent", // no UI; null unless exactly one credential can be released
  });
  if (!cred) return null;
  const res = await fetch("/login", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    credentials: "same-origin",
    body: JSON.stringify({ username: cred.id, password: cred.password }),
  });
  return res.ok ? cred : null;
}

// On sign-out: stop silent sign-in until the user picks an account again.
async function signOut() {
  await fetch("/logout", { method: "POST", credentials: "same-origin" });
  await navigator.credentials.preventSilentAccess?.();
}

FederatedCredential stores only which identity provider the user picked ({id, provider, name, iconURL, protocol}), not a token; get({federated: {providers: ["https://accounts.idp.example"]}}) returns it so you can run that IdP's flow for the right account. For new work, FedCM replaces it.

preventSilentAccess() sets a per-origin flag so mediation: "silent" returns null until the user chooses an account through UI. Call it on every sign-out, or users are signed straight back in. In Safari before 17 the method existed but always rejected with NotSupportedError.

FedCM: federated sign-in without third-party cookies

Chromium-only

FedCM is enabled by default in Chrome 108 and later (desktop and Android) and in other Chromium browsers. Firefox implements the Login Status API (navigator.login.setStatus() and the Set-Login header, Firefox 138) but not navigator.credentials.get({identity}). Safari doesn't implement it. Many sub-features (mode, fields, params, multiple IdPs) are newer and marked experimental on MDN.

Federated Credential Management moves "Sign in with IdP" out of iframes, third-party cookies and redirects into a browser-owned dialog. The relying party calls one API; the browser fetches the IdP's configuration and account list with the IdP's cookies (sent in a special Sec-Fetch-Dest: webidentity request), shows an account chooser, and hands the RP a token. The IdP never learns which RP the user visited until the user consents, and the RP never sees the IdP's cookies. For an installed PWA this is ideal: no out-of-scope navigation and no popup.

fedcm-rp.js
// Fetch the single-use nonce before the click: "active" mode needs transient user
// activation, and a slow network round trip inside the handler can outlast it.
let nonce = null;
getNonceFromServer().then((n) => { nonce = n; });

async function signInWithIdP({ fromButton = false, loginHint } = {}) {
  if (!("IdentityCredential" in window)) return fallbackOAuth(); // not Chromium
  if (!nonce) return fallbackOAuth();
  try {
    const credential = await navigator.credentials.get({
      identity: {
        // "active" requires a user gesture and can show the IdP's login popup (Chrome 132+).
        mode: fromButton ? "active" : "passive",
        context: "signin",                     // "signin" | "signup" | "use" | "continue"
        providers: [{
          configURL: "https://accounts.idp.example/fedcm.json",
          clientId: "rp-123",
          fields: ["name", "email", "picture"], // Chrome 132+
          params: { nonce, scope: "openid profile" }, // nonce moved into params
          ...(loginHint ? { loginHint } : {}),
        }],
      },
      mediation: "optional", // "silent" for automatic re-authentication of returning users
    });
    nonce = null; // single use; fetch a fresh one for any retry
    // credential.token is IdP-defined (often an ID token or authorization code).
    const res = await fetch("/auth/fedcm", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ token: credential.token, configURL: credential.configURL }),
    });
    if (!res.ok) throw new Error(`Server rejected the FedCM token: ${res.status}`);
  } catch (err) {
    // NetworkError: IdP config or accounts fetch failed; NotAllowedError: dismissed or disabled.
    if (err.name !== "AbortError") return fallbackOAuth();
  }
}

Older FedCM samples put nonce directly on the provider object. Chrome Platform Status lists the migration of nonce into params (with the IdentityCredentialError attribute renamed from code to error) for Chrome 143, so new code passes it in params, which the browser forwards to the IdP's ID assertion endpoint. context only changes the wording of the dialog title (sign in, sign up, use, or continue), not the protocol.

The IdP side consists of a well-known file (https://idp.example/.well-known/web-identity with provider_urls), a config file (accounts_endpoint, client_metadata_endpoint, id_assertion_endpoint, login_url, optional disconnect_endpoint and branding), and endpoints that must check Sec-Fetch-Dest: webidentity. The IdP tells the browser whether the user is signed in with the Set-Login: logged-in / logged-out response header or navigator.login.setStatus(); when the status is logged-out, FedCM fails silently without contacting the IdP. IdentityCredential.disconnect() (Chrome 122) revokes the RP–IdP connection. If your page enforces CSP connect-src, allow the IdP endpoints. In a cross-origin iframe, the embedder must grant allow="identity-credentials-get".

You rarely implement this yourself as an RP: identity providers ship FedCM inside their SDKs (Google Identity Services uses it in Chrome). Your job is to keep a redirect or popup fallback for other browsers and to verify the returned token on your server like any OIDC ID token (signature, iss, aud, nonce, expiry).

WebOTP: SMS codes bound to your origin

Chromium-only

WebOTP ships in Chrome on Android from version 84, supports cross-origin iframes on Android from Chrome 91 (with the otp-credentials Permissions Policy), and works cross-device on desktop Chrome 93+ when the user is signed in to Chrome on both the computer and an Android phone. Safari and Firefox don't implement the API.

WebOTP lets a page receive a one-time code from an SMS without the user copying it. The SMS must end with an origin-bound line naming the host and the code:

SMS body
Your Example verification code is 482913.

@example.com #482913

For a code requested inside a cross-origin iframe, the line names the top-level host first and the iframe's host second: @shop.example #482913 @pay.example. Browsers only deliver the code to a page whose origin matches, which also makes the format a useful anti-phishing signal. Safari doesn't implement the API, but it offers codes from Messages as autofill suggestions in fields marked autocomplete="one-time-code", and the origin-bound format is specified, in a WICG report co-edited by Apple and Google, for exactly that kind of autofill. One SMS template serves both.

webotp.js
async function autofillOtp(input, { timeoutMs = 120000 } = {}) {
  if (!("OTPCredential" in window)) return; // autocomplete="one-time-code" still helps
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(new DOMException("OTP wait timed out", "AbortError")), timeoutMs);
  // If the user types the code manually and submits, stop waiting.
  input.form?.addEventListener("submit", () => ac.abort(), { once: true });
  try {
    const otp = await navigator.credentials.get({ otp: { transport: ["sms"] }, signal: ac.signal });
    input.value = otp.code;
    input.form?.requestSubmit();
  } catch (err) {
    if (err.name !== "AbortError") console.warn("WebOTP failed:", err.name);
  } finally {
    clearTimeout(timer);
  }
}

autofillOtp(document.querySelector('input[autocomplete="one-time-code"]'));

On Android, Chrome shows a consent sheet when a matching SMS arrives; the code is released only after the user taps Allow. Treat SMS as the weakest factor on this page (SIM swapping, interception) and use it for phone-number verification and account recovery rather than as your primary second factor.

Digital Credentials API: identity documents from wallets

The Digital Credentials API lets a site request a verifiable credential, such as a mobile driver's license or an EU digital identity, from a wallet on the user's device, with selective disclosure ("over 18" without the birth date). Chrome enabled it by default in Chrome 141 (same-device presentation on Android, cross-device from desktop through a QR code and a proximity check). Safari 26 supports it for the ISO/IEC 18013-7 Annex C protocol, "org-iso-mdoc", with Apple Wallet and registered iOS apps. Mozilla's standards position is negative. It isn't a sign-in mechanism: use it for identity verification, age gates and KYC steps, next to a normal account sign-in.

digital-credentials.js
async function verifyAge() {
  if (typeof DigitalCredential === "undefined") return fallbackVerification();
  const protocol = ["openid4vp-v1-unsigned", "org-iso-mdoc"]
    .find((p) => DigitalCredential.userAgentAllowsProtocol(p));
  if (!protocol) return fallbackVerification();

  // The server builds the protocol-specific request (nonce, DCQL query or mdoc DeviceRequest).
  const request = await fetch(`/identity/request?protocol=${protocol}`).then((r) => r.json());
  try {
    const credential = await navigator.credentials.get({
      digital: { requests: [{ protocol, data: request }] },
      mediation: "required", // Safari 26.4 made user mediation implicitly required anyway
    });
    // credential.protocol and credential.data (encrypted response): verify on the server.
    return fetch("/identity/verify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ protocol: credential.protocol, data: credential.data }),
    }).then((r) => r.json());
  } catch (err) {
    // NotAllowedError: user declined; OperationError (Safari 27+): platform cancellation.
    return fallbackVerification(err);
  }
}

The API changed during its origin trial: it moved from navigator.identity.get() to navigator.credentials.get({digital}), providers became requests and request became data; Safari 27 renamed the request dictionary to DigitalCredentialGetRequest and fixed Credential.type to return "digital". Code written against 2024 explainers needs updating.

Sessions in installed PWAs: where cookies live

The single most surprising thing about authentication in installed PWAs is that on Apple platforms the installed app does not share its session with the browser. Whether a user must sign in again after installing depends on the platform:

Platform Installed app's cookies and storage Consequence
Chrome and Edge on Windows, macOS, Linux, ChromeOS Same browser profile as the tabs: cookies, IndexedDB, Cache Storage and service workers are shared with the same origin in the browser Signed in in the browser = signed in in the app. Signing out anywhere signs out everywhere
Chrome on Android (WebAPK) and Trusted Web Activities Chrome's storage for the origin, shared with Chrome tabs Same as desktop Chromium
Safari on macOS (web apps in the Dock, Safari 17+) Separate storage. When the web app is created, Safari "will copy the website's cookies to the web app"; "Safari does not copy over any other kind of local storage". After creation, nothing is shared Signed in at install time (for cookie-based sessions only); the two sessions then diverge
Safari on iOS and iPadOS (Home Screen web apps) Separate container per web app. Since Safari 17.2 (iOS 17.2), Safari's cookies are copied when the site is saved to the Home Screen from Safari; no other storage is copied. Whether cookies are copied when Chrome, Edge or Firefox for iOS creates the web app isn't documented Same as macOS: cookie sessions survive installation, localStorage/IndexedDB tokens don't

On iOS, each Home Screen install is its own app with its own storage, and iOS has always allowed installing the same site more than once, which WebKit describes as useful for "multiple accounts, separate work vs personal usage". Since iOS 26, every site added to the Home Screen opens as a web app by default, so more users end up in this separate container than before.

What follows for your design:

  • Put the session in a cookie. A server-set HttpOnly session cookie is copied at install on Apple platforms and shared on Chromium. A token in localStorage or IndexedDB is not copied, so iOS and macOS users land signed out in the app they just installed.
  • Expect two independent sessions on Apple platforms. Signing out in Safari doesn't sign out the web app, and vice versa. Treat each as a device in your "active sessions" list, and let users revoke them server-side.
  • Make re-authentication cheap. Passkeys live in iCloud Keychain, not in the web app's storage, so a passkey created in Safari works in the Home Screen app (same RP ID) and vice versa. That's the strongest argument for passkeys in PWAs on Apple devices.
  • Cookie lifetimes. Browsers cap cookie lifetimes: Chromium clamps Expires/Max-Age to 400 days, and WebKit's tracking prevention caps cookies set with document.cookie to seven days, so set session cookies from the server. WebKit's seven-day limit on script-writable storage counts days of use separately for Home Screen web apps, which "have their own counter of days of use", so an app that people actually open keeps its data. The Storage Quotas & Persistence and Privacy & Storage Partitioning pages go deeper.

OAuth and OpenID Connect in standalone mode

A classic OAuth or OIDC sign-in navigates to the identity provider's origin and back to your redirect_uri. In a browser tab that's invisible plumbing. In an installed PWA, the IdP's origin is outside your manifest's scope, and each platform handles out-of-scope navigation differently (the Display Modes page covers the general rules):

Platform Top-level navigation to the IdP window.open() to the IdP
Desktop Chromium Stays in the app window; a bar shows the IdP's origin with a close button. Returning to an in-scope URL hides the bar Opens a popup window (with popup or size features) or a browser tab; shares the profile's cookies
Chrome on Android (WebAPK, TWA) Stays in the app's task with a Custom Tabs-style toolbar showing the origin Opens a new Chrome Custom Tab or tab; shared storage
Safari on macOS (Dock web app) Out-of-scope links "will open in the default browser", except that OAuth "on a third-party domain will still open in your web app" through heuristics (WWDC23) "Links loaded through window.open will always open in the web app regardless of scope", which is Apple's documented way to keep an OAuth flow in the app
Safari on iOS / iPadOS (Home Screen web app) "Links outside the scope will open in Safari View Controller" (WWDC23) Treat like an out-of-scope link; test on each iOS version you support

Apple's Safari 17.2 release notes list fixes for macOS web apps where "sign in pages sometimes unexpectedly open in Safari instead of the web app" and where "some login pages unexpectedly open in Safari", which tells you how heuristic this is. Two consequences matter for correctness, not just UX:

  1. Client-side flow state can be lost. If the IdP page opens in another context (the default browser on macOS, possibly Safari View Controller on iOS), a PKCE code_verifier or state kept in the app's sessionStorage isn't available where the callback lands. Keep flow state on the server, keyed by the state parameter.
  2. The callback may land in a different cookie jar. On Apple platforms the web app has its own cookies. A session cookie set by your callback in Safari's context doesn't sign in the web app.

Patterns that work

In order of preference:

  1. Don't leave the origin. Passkeys on your own RP ID, and FedCM on Chromium, need no redirect at all. A first-party sign-in page on your own origin (in scope) that talks to your IdP server-to-server keeps everything in the app window.
  2. Backend-for-frontend authorization code flow in the same window. Your server starts the flow (generates state, nonce and the PKCE verifier, stores them server-side), redirects the window to the IdP, handles the callback on an in-scope redirect_uri, exchanges the code, and sets a __Host- session cookie. This works as-is on Chromium (desktop and Android), where the out-of-scope page stays in the app window with shared storage.
  3. Popup where the platform needs it. On macOS Safari web apps, use window.open() so the flow stays in the app. The callback page, on your origin, notifies the opener and closes itself.
  4. Context-independent handoff as a last resort. If the callback can land outside the app's storage (verify on your target iOS versions), let the app poll the server for completion: the app starts a login transaction bound to an HttpOnly cookie in its jar, the callback marks the transaction complete in whatever context it lands in, and the app redeems the transaction to receive its own session cookie. Because this decouples the device that authenticates from the one that receives the session, protect it like OAuth's device flow: short-lived single-use transactions, an unguessable ID, and a short confirmation code displayed in both places so a user can't be tricked into completing an attacker's transaction.

Cross-Origin-Opener-Policy interacts with popups: if your page sends COOP: same-origin, the opener relationship with the popup is severed as soon as it navigates to the IdP, so window.opener is null in your callback page. Use same-origin-allow-popups on the page that opens the popup, or communicate through a same-origin BroadcastChannel, which doesn't need the opener.

A BFF flow with popup support

auth-client.js
// Chooses a redirect or a popup, and learns the result through a same-origin channel.
const ua = navigator.userAgent;
const isStandalone = matchMedia("(display-mode: standalone)").matches ||
  navigator.standalone === true; // navigator.standalone: legacy iOS flag
// iPadOS reports a Macintosh UA; touch support tells it apart from a Mac.
const isIOS = /iPhone|iPad/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
// Chrome and Edge installed apps on macOS also have "Macintosh" in the UA: exclude them.
const isMacSafari = /Macintosh/.test(ua) && !isIOS && !/Chrome\/|Chromium\/|Edg\/|Firefox\//.test(ua);
const isAppleWebApp = isStandalone && (isIOS || isMacSafari);

export function signInWithOidc({ returnTo = location.pathname } = {}) {
  const start = `/auth/start?returnTo=${encodeURIComponent(returnTo)}`;

  if (!isAppleWebApp) {
    // Chromium (tab or installed) and Safari tabs: same-window redirect, storage is shared.
    location.assign(start);
    return Promise.resolve();
  }

  // macOS Safari web apps: window.open keeps the flow inside the web app (WWDC23).
  return new Promise((resolve, reject) => {
    const channel = new BroadcastChannel("auth");
    const popup = window.open(`${start}&mode=popup`, "oidc", "popup,width=520,height=680");
    if (!popup) {
      channel.close();
      location.assign(start); // popup blocked: fall back to a redirect
      return;
    }
    const timer = setTimeout(() => finish(new Error("Sign-in timed out")), 10 * 60 * 1000);
    function finish(err) {
      clearTimeout(timer);
      channel.close();
      err ? reject(err) : resolve();
    }
    channel.onmessage = (event) => {
      if (event.data?.type === "signed-in") finish();
      else if (event.data?.type === "sign-in-failed") finish(new Error(event.data.reason));
    };
  });
}
auth-server.mjs (excerpt, Express)
import crypto from "node:crypto";

const ISSUER = "https://idp.example";
const CLIENT_ID = process.env.OIDC_CLIENT_ID;
const CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET; // confidential client: the BFF
const REDIRECT_URI = "https://app.example.com/auth/callback"; // inside the manifest scope
const pending = new Map(); // use Redis or your DB in production; entries expire

const b64url = (buf) => buf.toString("base64url");

app.get("/auth/start", (req, res) => {
  const state = b64url(crypto.randomBytes(32));
  const nonce = b64url(crypto.randomBytes(32));
  const verifier = b64url(crypto.randomBytes(32));
  const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
  // Only same-origin paths: "//evil.example" and "/\evil.example" are protocol-relative
  // URLs to browsers and would turn the callback into an open redirect.
  const rawReturnTo = typeof req.query.returnTo === "string" ? req.query.returnTo : "/";
  const returnTo = /^\/(?![/\\])/.test(rawReturnTo) ? rawReturnTo : "/";
  // Flow state lives on the server, keyed by state: it survives a context switch.
  pending.set(state, { nonce, verifier, returnTo, popup: req.query.mode === "popup", expires: Date.now() + 600_000 });

  const url = new URL(`${ISSUER}/authorize`);
  url.search = new URLSearchParams({
    response_type: "code",
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: "openid profile email",
    state,
    nonce,
    code_challenge: challenge,
    code_challenge_method: "S256",
  }).toString();
  res.redirect(url.href);
});

app.get("/auth/callback", async (req, res) => {
  const flow = pending.get(req.query.state);
  pending.delete(req.query.state);
  if (!flow || flow.expires < Date.now()) {
    return res.status(400).send("Sign-in expired. Please try again.");
  }
  if (typeof req.query.code !== "string") {
    // The IdP redirected back with ?error=access_denied (or similar): tell a waiting app window.
    const reason = JSON.stringify(String(req.query.error ?? "unknown")).replace(/</g, "\\u003c");
    return flow.popup
      ? res.type("html").send(`<!doctype html><script>
          new BroadcastChannel("auth").postMessage({ type: "sign-in-failed", reason: ${reason} });
          window.close();
        </script><p>Sign-in was cancelled.</p>`)
      : res.redirect("/login?error=cancelled");
  }
  const tokenRes = await fetch(`${ISSUER}/token`, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`,
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: req.query.code,
      redirect_uri: REDIRECT_URI,
      code_verifier: flow.verifier,
    }),
  });
  if (!tokenRes.ok) return res.status(502).send("Sign-in failed.");
  const tokens = await tokenRes.json();
  const claims = await verifyIdToken(tokens.id_token, { issuer: ISSUER, audience: CLIENT_ID, nonce: flow.nonce });

  req.session.regenerate((err) => {
    if (err) return res.status(500).send("Session error.");
    req.session.userId = claims.sub;
    req.session.refreshToken = tokens.refresh_token; // stays on the server, never sent to JS
    if (flow.popup) {
      // Same-origin page: notify the app window, then close the popup.
      res.type("html").send(`<!doctype html><script>
        new BroadcastChannel("auth").postMessage({ type: "signed-in" });
        window.close();
      </script><p>Signed in. You can close this window.</p>`);
    } else {
      res.redirect(flow.returnTo);
    }
  });
});

verifyIdToken() stands for your JOSE library's JWT verification (signature against the IdP's JWKS, iss, aud, exp, nonce). The inline script needs a CSP nonce or hash if your pages send a strict Content Security Policy.

Token storage and session security

The browser offers several places to keep proof of authentication. They differ in exactly one property that matters most: can a script injected into your page (XSS, a compromised dependency) read it?

Storage Readable by injected script Survives reload Copied into Apple web apps at install Notes
HttpOnly; Secure cookie No Yes Yes Sent automatically, so pair with SameSite=Lax or Strict and CSRF defenses for state-changing requests
JavaScript memory Yes, while the page runs No No Reasonable for short-lived access tokens obtained from a BFF
sessionStorage Yes Per tab No Lost when an out-of-scope flow opens another context
localStorage / IndexedDB Yes Yes No Long-lived bearer tokens here are exfiltrated by any XSS
Service worker memory or IndexedDB Yes (via the worker's global) Worker restarts clear memory No Workers are terminated at will; don't treat them as a vault

Recommendations that follow from the table and from current OAuth guidance (the OAuth 2.0 Security Best Current Practice, RFC 9700, and the IETF draft OAuth 2.0 for Browser-Based Applications):

  • Prefer a backend-for-frontend. Your server is the OAuth client, holds refresh tokens, and gives the browser a __Host- session cookie: Set-Cookie: __Host-sid=…; Path=/; Secure; HttpOnly; SameSite=Lax. The __Host- prefix guarantees Secure, Path=/ and no Domain, so a subdomain can't overwrite it.
  • If the browser must hold tokens, keep access tokens short-lived and in memory, use refresh token rotation with reuse detection, and consider sender-constrained tokens with DPoP (RFC 9449) using a non-extractable CryptoKey stored in IndexedDB.
  • Bind sessions to the device where you can. Device Bound Session Credentials (DBSC) bind a session to a key pair held in hardware (the TPM on Windows), so a stolen cookie is useless on another machine. Chrome rolled it out gradually from Chrome 145 on Windows and Chrome 147 on macOS; other platforms and browsers don't support it, and Firefox's position is negative. The server registers a session through a Secure-Session-Registration response header and must keep working for clients that ignore it, so treat it as an extra layer.
  • Re-authenticate for sensitive actions. A fresh navigator.credentials.get() with userVerification: "required" is a strong, quick step-up for changing email, payment details or passkeys. Record authTime in the session and check it on the server.
  • Clear everything on sign-out. Delete the server session, then respond with Clear-Site-Data: "cache", "cookies", "storage" (Chrome 61, Firefox 63, Safari 17; Chromium's "cache" support is partial), and in the page, delete Cache Storage entries that hold user-specific responses and call navigator.credentials.preventSilentAccess().

Service workers and authenticated content

A service worker sees every request from its pages, including credentialed ones. Rules that prevent the classic leaks:

  • Don't cache per-user responses under shared keys. A cache-first route for /api/me serves the previous user's profile after an account switch. Cache per-user data in IndexedDB keyed by user ID, or cache with a user-scoped cache name (api-${userId}) and delete it on sign-out.
  • Never cache authentication endpoints. Exclude /auth/*, /webauthn/* and token endpoints from every route, and send Cache-Control: no-store.
  • Handle 401 in the page, not by replaying in the worker. A worker that transparently retries with a refreshed token hides session expiry and can loop. Surface 401 to the page, re-authenticate, then retry.
  • Cookies aren't readable in the worker when they're HttpOnly. cookieStore in a worker sees only non-HttpOnly cookies. Your worker doesn't need the session cookie anyway: fetch() from the worker sends same-origin cookies automatically.

The Service Worker Security page covers the rest of the worker threat model.

Offline authentication

A server can't verify anything while the device is offline, so "offline sign-in" really means three separate things:

  1. Keeping an existing session usable offline. The session cookie is still there; you just can't reach the server. Let the app open with cached data for the signed-in user (from IndexedDB, keyed by user ID), mark the UI as offline, and queue writes (Offline-First Data & Sync). When connectivity returns, the first request either succeeds or gets a 401; on 401, re-authenticate before replaying queued writes, so they're attributed to the right user.
  2. Protecting offline data at rest. If cached data is sensitive, encrypt it with a key the user must unlock. The WebAuthn PRF extension derives a stable secret from a passkey during get() without any server involvement: the same credential and salt always yield the same 32 bytes. Support depends on browser and passkey provider: Chrome 116+, Safari 18+ and Firefox 139+ (not on macOS before that) implement the extension. Safari 18 added it for platform passkeys, and Safari 26.4 extended it to security keys through CTAP's hmac-secret extension, and getClientExtensionResults().prf.enabled at registration tells you whether the provider supports it.
  3. Locking the app. A local "unlock with Face ID" gate can use the same PRF call: if the derived key decrypts your data, the user proved possession of the passkey. It isn't server authentication, so don't let it authorize anything the server will accept later without a real session.
offline-vault.js
// Derives an AES-GCM key from a passkey via the PRF extension, for encrypting offline data.
const PRF_SALT = new TextEncoder().encode("example.com offline-vault v1"); // fixed per purpose

export async function unlockVault({ rpId, credentialIds }) {
  const assertion = await navigator.credentials.get({
    publicKey: {
      challenge: crypto.getRandomValues(new Uint8Array(32)), // local: no server verifies this
      rpId,
      allowCredentials: credentialIds.map((id) => ({ type: "public-key", id })),
      userVerification: "required",
      extensions: { prf: { eval: { first: PRF_SALT } } },
    },
  });
  const prf = assertion.getClientExtensionResults().prf?.results?.first;
  if (!prf) throw new Error("This passkey provider doesn't support PRF");

  // HKDF turns the PRF output into a non-extractable AES key.
  const ikm = await crypto.subtle.importKey("raw", prf, "HKDF", false, ["deriveKey"]);
  return crypto.subtle.deriveKey(
    { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(32), info: new TextEncoder().encode("vault-aes-gcm") },
    ikm,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"],
  );
}

export async function sealRecord(key, record) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const data = new TextEncoder().encode(JSON.stringify(record));
  const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data);
  return { iv, ciphertext }; // store both in IndexedDB
}

export async function openRecord(key, { iv, ciphertext }) {
  const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext);
  return JSON.parse(new TextDecoder().decode(plain));
}

If the user deletes the passkey, data encrypted with its PRF output is unrecoverable. Keep the server copy authoritative, or wrap the data key with more than one credential.

Browser support

Support data as of September 2026. See MDN's Web Authentication API and Credential Management API pages and caniuse: Web Authentication API for live data.

Feature Chrome / Edge Chrome Android Safari macOS Safari iOS Firefox
WebAuthn create() / get() ✅ 67 / 18 ✅ 70 ✅ 13 ✅ 13 ✅ 60
Conditional mediation (autofill) ✅ 108 ✅ 108 ✅ 16 ✅ 16 ✅ 119
authenticatorAttachment ✅ 98 ✅ 98 ✅ 15.5 ✅ 15.5 ✅ 120
JSON helpers (parse*FromJSON, toJSON) ✅ 129 ✅ 129 ✅ 18.4 ✅ 18.4 ✅ 119
getClientCapabilities() ✅ 133 ✅ 133 ✅ 17.4 ✅ 17.4 ✅ 135
hints ✅ 128 ✅ 128 ❌ ❌ ❌
Related Origin Requests ✅ 128 ✅ 128 ✅ 18 ✅ 18 ❌
Conditional create (passkey upgrades) ✅ 136 ✅ 142 ✅ 18 ✅ 18 ❌
Signal API ✅ 132 ✅ ✅ 26 ✅ 26 ❌
Immediate UI mode (uiMode: "immediate") ✅ 148 ✅ 148 ❌ ❌ ❌
PRF extension ✅ 116 ✅ 116 ✅ 18 ✅ 18 ⚠️ 139
PasswordCredential, FederatedCredential ✅ 51 / 79 ✅ 51 ❌ ❌ ❌
FedCM ✅ 108 ✅ 108 ❌ ❌ ⚠️ 138
WebOTP ✅ 93 (cross-device) ✅ 84 ❌ ❌ ❌
Digital Credentials API ✅ 141 ✅ 141 ✅ 26 ✅ 26 ❌
Clear-Site-Data ⚠️ 61 ⚠️ 61 ✅ 17 ✅ 17 ✅ 63

⚠️ PRF in Firefox: supported from 139 (earlier versions partial, not on macOS); Firefox for Android supports it at creation from 149. FedCM in Firefox: only the Login Status API (navigator.login.setStatus(), Set-Login). Clear-Site-Data in Chromium: the "cache" directive is partial. Signal API on Android: MDN lists Chrome 132, while Chrome Platform Status lists Android 144. Android WebView supports neither conditional mediation nor, without app configuration, WebAuthn; Trusted Web Activities run in Chrome and get full support. Chrome on iOS and every other iOS browser use WebKit, so they follow the Safari iOS column. MDN's compatibility data lists Safari 27 for the identity and otp members of navigator.credentials.get(), but Safari 27's release notes announce neither FedCM nor WebOTP (only a fix to which credential types may be combined in one get() call), so the table shows both as unsupported; feature-detect IdentityCredential and OTPCredential rather than trusting version numbers.

Common pitfalls

  1. Choosing the wrong RP ID. Using www.example.com locks credentials to that host; example.com works on every subdomain. Changing it later strands every passkey.
  2. Putting PII in user.id. The user handle is stored on authenticators and returned to anyone who can trigger a sign-in. Use random bytes.
  3. Reusing challenges. A challenge must be single-use and expire. Delete it before verifying, even on failure.
  4. Rejecting signCount of 0. Synced passkeys always report 0; only treat a decreasing non-zero counter as suspicious.
  5. Forgetting autocomplete="username webauthn" or rendering the field after calling conditional get(). The autofill never shows passkeys.
  6. Two pending WebAuthn requests. Starting a modal get() while a conditional one is pending fails. Abort first.
  7. Token in localStorage on iOS. It isn't copied into the Home Screen app at install, and any XSS can read it. Use an HttpOnly cookie.
  8. PKCE verifier in sessionStorage with out-of-scope redirects. The callback may land in a different context. Keep flow state on the server.
  9. COOP: same-origin on a page that opens an OAuth popup. window.opener becomes null. Use same-origin-allow-popups or a BroadcastChannel.
  10. No preventSilentAccess() on sign-out in Chromium: the next visit signs the user straight back in with the stored password.
  11. Caching /api/me in the service worker. The next user of a shared device sees the previous user's data.
  12. Treating WebOTP or SMS as strong authentication. It verifies a phone number, not a person.

Debugging

  • Chrome DevTools WebAuthn panel (More tools → WebAuthn) replaces real authenticators with virtual ones: create CTAP2 or U2F authenticators with or without resident key and user verification support, inspect stored credentials, watch the sign count, and simulate UV success or failure. Playwright and WebDriver expose the same virtual authenticators for automated tests.
  • Chrome's password manager at chrome://password-manager/passwords lists passkeys; delete test passkeys there. On Apple devices use the Passwords app; on Android, Google Password Manager.
  • FedCM. DevTools reports FedCM failures (config fetch, accounts endpoint, CSP connect-src) in the Console and Issues panels, and the Network panel shows the Sec-Fetch-Dest: webidentity requests to the IdP.
  • Safari. Use Web Inspector attached to the Home Screen web app or Dock web app (Develop menu) to check which cookies the app container really has after install.
  • iOS standalone flows. Test OAuth, popups and passkeys in a freshly installed Home Screen app, not in a Safari tab, on each iOS version you support.

Further reading

On this site

External references