Passkey Authentication
This guide details how to implement returning user authentication using discoverable credentials, both through explicit button triggers and seamless browser autofill suggestions (Conditional UI).
Server-Side
Section titled “Server-Side”Options Generation
Section titled “Options Generation”Create an endpoint that generates WebAuthn request parameters using a vetted library per standards.
- Use the predefined RP ID: Use the predefined proper RP ID as a constant string and include it as
rpIdin the returned request options so the client can passpublicKey.rpIddirectly to Signal API calls. - Generate challenge: Generate a high-entropy, cryptographically secure random buffer, store it securely in the user’s session, and encode it as Base64URL.
- Discoverable Credentials mapping: Specify an empty array
[]forallowCredentials. This requests discoverable credentials, meaning the user does not need to enter their username first; the passkey provider will present available accounts. - User Verification level: Set
userVerification: "preferred"(or"required"if explicitly mandated by corporate compliance policies).- The requested
userVerificationconstraint level MUST be persisted inside the server session record at the options endpoint, rather than passed back from the client via query strings. This allows the verification endpoint to enforce strict matching constraints safely without risk of client manipulation.
- The requested
// Options generation example (discoverable flow)const options = { challenge: serverGeneratedBase64UrlChallenge, // High-entropy random challenge stored in session rpId: "example.com", allowCredentials: [], // Request discoverable passkeys userVerification: "preferred",};
// Persist expected UV level to user sessionreq.session.expectedUserVerification = "preferred";Verification Endpoint
Section titled “Verification Endpoint”Securely verify the assertion returned by the client to authenticate the user:
- Validate session challenge: Enforce strict challenge matching between the client response and the expected challenge stored in the session.
- Enforce UV Preferences:
- Allow UV-less authenticators (e.g., authenticator screen locks disabled) if the session’s
expectedUserVerificationrequested"preferred", by passingrequireUserVerification: falseto your server-side verification library. If requested"required", enforce biometrics/PIN entry strictly.
- Allow UV-less authenticators (e.g., authenticator screen locks disabled) if the session’s
- Clean Server Error 404: If the credential ID returned by the client is not found in the database, return an explicit HTTP
404error so the client can trigger the Signal API.
Client-Side Logic
Section titled “Client-Side Logic”HTML Form Annotation
Section titled “HTML Form Annotation”Annotate your username and password inputs to natively leverage Conditional UI. Autocomplete tokens combine the webauthn spec parameters.
Use the autofocus attribute on dedicated sign-in pages and sign-in dialogs so that the autofill options are immediately displayed. Do not use the autofocus attribute on pages or dialogs that are not dedicated sign-in forms. Do not use the autofocus attribute on sign-in forms that are embedded in a larger page. Ensure that pages have a concise but descriptive page title, and that dialogs have a concise but descriptive accessible name when the autofocus attribute is used on dedicated sign-in pages and dialogs.
Use the aria-describedby attribute on elements with autofocus so instructions and context are provided for screen reader users.
<h1 id="signin-passkey">Sign In with Passkey</h1><!-- Autocomplete tokens must contain webauthn space-separated --><form id="signin-form"> <input type="text" name="username" aria-describedby="signin-passkey" autocomplete="username webauthn" autofocus data-testid="username-field" /> <input type="password" name="password" autocomplete="current-password" /> <button type="submit">Sign in</button></form>Explicit Button Flow
Section titled “Explicit Button Flow”Trigger passkey authentication when a user clicks a “Sign in with passkey” button. Abort any ongoing form autofill (Conditional Get) calls before invoking the passkey prompt.
Conditional Mediation Flow (Form Autofill)
Section titled “Conditional Mediation Flow (Form Autofill)”Activate form autofill suggestions on page load to offer passkey authentication natively when users focus on sign-in fields:
- Feature detect: Call
PublicKeyCredential.getClientCapabilities()on page load and skip signing in with passkey ifconditionalGetis not available. - Decode options: Decode fetched credential JSON object with
PublicKeyCredential.parseRequestOptionsFromJSON(). - Invoke Conditional Get: Call
navigator.credentials.get()withmediation: "conditional"and pass anAbortControllersignal. This registers autofill silently without rendering a passkey dialog. - Try/Catch Exception Segregation: Wrap
navigator.credentials.getcall in try/catch block:NotAllowedError: The user cancelled or timed out the passkey login prompt.AbortError: The authentication request was cancelled programmatically.
- Call Signal API: Wrap server verification
fetch()call in a try/catch block:- Show an error message for the user to understand what went wrong.
- Call
signalUnknownCredential()ONLY when the server explicitly responds with HTTP status404(Credential not found) and the user is unauthenticated. - Always pass
rpId: publicKey.rpIdfrom the server’s request options. DO NOT deriverpIdfromwindow.location.hostnameon the client, as the server’s configured RP ID may be a registrable parent domain (such asexample.comonlogin.example.com) or use Related Origin Requests. - The
credentialIdparameter passed tosignalUnknownCredential()MUST strictly be the Base64URL-encoded credential ID string (e.g.,encoded.id), NOT the raw ArrayBuffer objectcredential.rawId.
- Encode the response: Encode the credential
AuthenticatorAssertionResponsewith.toJSON()before sending it to the server for verification.
// optionsFetch and loginVerifyFetch are app-defined HTTP methodsimport { optionsFetch, loginVerifyFetch } from "./api.js";
let autofillAbortController = new AbortController();
async function initializeConditionalAutofill() { // Feature detect Conditional Get autofill support const capabilities = await PublicKeyCredential.getClientCapabilities(); if (capabilities.conditionalGet === true) { const loginOptionsJSON = await optionsFetch(); const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(loginOptionsJSON);
try { // Initiate Conditional UI form autofill suggestions const credential = await navigator.credentials.get({ publicKey, signal: autofillAbortController.signal, mediation: "conditional", });
// Segregated verification fetch const encoded = credential.toJSON(); const response = await loginVerifyFetch(encoded); if (!response.ok && response.status === 404) { // Note: this code path runs pre-authentication, satisfying the unauth precondition if (PublicKeyCredential.signalUnknownCredential) { await PublicKeyCredential.signalUnknownCredential({ rpId: publicKey.rpId, // Always use the server-provided rpId; DO NOT use window.location.hostname credentialId: encoded.id, }); } } } catch (err) { // Silently swallow expected client WebAuthn exceptions if (["NotAllowedError", "AbortError"].includes(err.name)) { return; } console.error("Unexpected conditional get error:", err); } }}
async function triggerButtonAuthentication() { // Abort any pending Conditional Get call to prevent passkey prompt collisions autofillAbortController.abort(); autofillAbortController = new AbortController(); // Reset controller for next triggers
const loginOptionsJSON = await optionsFetch(); const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(loginOptionsJSON);
let credential; try { // Passkey explicit prompt trigger credential = await navigator.credentials.get({ publicKey, signal: autofillAbortController.signal, }); } catch (err) { if (err.name === "NotAllowedError") { console.log("User cancelled passkey login."); } else if (err.name === "AbortError") { console.log("The authentication operation was aborted."); } // Re-arm Conditional autofill Suggestions after cancelled explicit button prompts initializeConditionalAutofill(); return; // Safe exit }
// Segregated verification try/catch (HTTP 404 trigger) const encoded = credential.toJSON(); try { const response = await loginVerifyFetch(encoded); if (!response.ok && response.status === 404) { // Note: this code path runs pre-authentication, satisfying the unauth precondition if (PublicKeyCredential.signalUnknownCredential) { await PublicKeyCredential.signalUnknownCredential({ rpId: publicKey.rpId, // Always use the server-provided rpId; DO NOT use window.location.hostname credentialId: encoded.id, // Base64URL-encoded credential ID }); } } } catch (serverErr) { console.error("Verification request error:", serverErr); }}
// Trigger Conditional Get on loadwindow.addEventListener("DOMContentLoaded", initializeConditionalAutofill);Fallback Strategies
Section titled “Fallback Strategies”Passkey feature detection fallback
Section titled “Passkey feature detection fallback”Baseline: webauthn / api.PublicKeyCredential.getClientCapabilities_static
Load webauthn-polyfills so PublicKeyCredential.getClientCapabilities() is available whenever PublicKeyCredential is supported:
// With a bundler (`npm install webauthn-polyfills`):import 'webauthn-polyfills';Without a bundler, serve the package from your own origin, or import it from a CDN such as https://esm.sh/[email protected] (pin the exact version). For third-party URLs, add Subresource Integrity: the integrity attribute on the <script> tag, or an import map’s integrity field for import statements.
Signal API Synchronization Fallback
Section titled “Signal API Synchronization Fallback”Baseline: webauthn-signals
The WebAuthn Signal API (webauthn-signals) is a progressive optimization used to keep password managers in sync with the server credential state.
- Fallback Experience: Gated via
if (PublicKeyCredential.signalUnknownCredential). If unsupported, the background verification sync is bypassed gracefully without throwing browser exceptions.
Easy JSON Serialization Fallback
Section titled “Easy JSON Serialization Fallback”Baseline: webauthn / api.PublicKeyCredential.parseRequestOptionsFromJSON_static
Load webauthn-polyfills so PublicKeyCredential.parseRequestOptionsFromJSON() and PublicKeyCredential.prototype.toJSON() are available whenever PublicKeyCredential is supported:
// With a bundler (`npm install webauthn-polyfills`):import 'webauthn-polyfills';Without a bundler, serve the package from your own origin, or import it from a CDN such as https://esm.sh/[email protected] (pin the exact version). For third-party URLs, add Subresource Integrity: the integrity attribute on the <script> tag, or an import map’s integrity field for import statements.
Compatibility notes: Badges like Baseline: … mark web features referenced by this guide. Baseline status describes availability across the core browser set — it does not mean every browser or older version supports the feature. Feature data as published upstream, synced 2026-11-12.
This demo needs browser permissions or external resources, so it is not embedded. Open the demo as a standalone page (it runs in your browser; some features may require a supported browser and your permission).
Counter-example (negative demo) — what this guide tells you to avoid.
Source: GoogleChrome/modern-web-guidance-src (Apache-2.0), upstream commit 179dbc6, synced 2026-11-12. Local changes: macros expanded to links/badges, first heading de-duplicated, demo and this footer added. This is an independent, unofficial site — not a Google publication.