The essentials
Quick reference
One focused task per row. Jump to the related section for complete, working examples.
| Use | Syntax | Examples |
|---|---|---|
| Detect the WebAuthn interface | if (!window.PublicKeyCredential) showPasswordFallback() | View examples |
| Require a secure context | if (!isSecureContext) throw new Error('WebAuthn requires a secure context') | View examples |
| Start registration on the server | const optionsJSON = await fetch('/api/passkeys/register/options', { method: 'POST' }).then(r => r.json()) | View examples |
| Restrict credential operations | Permissions-Policy: publickey-credentials-create=(self), publickey-credentials-get=(self) | View examples |
| Parse registration options | const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJSON.publicKey) | View examples |
| Create a credential | const credential = await navigator.credentials.create({ publicKey, signal: controller.signal }) | View examples |
| Require discoverability | authenticatorSelection: { residentKey: 'required', userVerification: 'required' } | View examples |
| Exclude registered credentials | excludeCredentials: credentials.map(({ id, transports }) => ({ type: 'public-key', id, transports })) | View examples |
| Minimize attestation | attestation: 'none' | View examples |
| Serialize a Level 3 credential | const payload = credential.toJSON() | View examples |
| Encode bytes as base64url | const encoded = bytesToBase64url(new Uint8Array(credential.rawId)) | View examples |
| Verify registration on the server | await verifier.verifyRegistrationResponse({ response, expectedChallenge, expectedOrigin, expectedRPID }) | View examples |
| Store the verified credential | await credentialStore.insert({ accountId, userHandle, credentialId, publicKey, counter, transports }) | View examples |
| Parse authentication options | const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON.publicKey) | View examples |
| Request an assertion | const assertion = await navigator.credentials.get({ publicKey, signal: controller.signal }) | View examples |
| Enable usernameless discovery | delete publicKey.allowCredentials | View examples |
| Require an expected origin | expectedOrigin: 'https://login.example.com' | View examples |
| Require the registered RP ID | expectedRPID: 'example.com' | View examples |
| Enforce user verification | requireUserVerification: true | View examples |
| Detect conditional mediation | const available = await PublicKeyCredential.isConditionalMediationAvailable() | View examples |
| Mark the autofill field | <input name="username" autocomplete="username webauthn"> | View examples |
| Start a conditional request | const assertion = await navigator.credentials.get({ publicKey, mediation: 'conditional', signal }) | View examples |
| Record transport hints | const transports = credential.response.getTransports() | View examples |
| Cancel a stale ceremony | controller.abort(new DOMException('Replaced by a new request', 'AbortError')) | View examples |
| Classify expected DOM errors | if (error instanceof DOMException && error.name === 'NotAllowedError') showRetry() | View examples |
WebAuthn lets an authenticator create an origin-bound public-key credential while the relying party stores only the credential identifier, public key, and related metadata. The browser coordinates user interaction, but it does not authenticate an application session by itself: a trusted server must generate and bind each challenge, validate the returned ceremony, look up the account, and issue the session. Use a maintained WebAuthn server library, keep recovery paths at least as strong as sign-in, and treat the examples below as ceremony boundaries rather than a replacement for protocol validation.
Step by step
Detailed examples
Keep challenges, policy, verification, and sessions on the relying-party server
WebAuthn is exposed only in secure contexts, normally HTTPS (with localhost treated specially for development), but transport security is only the first gate. The authenticated server should create at least 16 bytes of cryptographically random challenge data, bind it to the current account or pre-authentication transaction, intended ceremony, RP ID, origin policy, and expiry, and accept it exactly once. The page merely passes server-generated options to the browser and returns the result. Never let client code choose the expected challenge, expected origin, RP ID, verification requirement, account owner, or post-verification session. Cross-origin iframe use also requires explicit Permissions Policy delegation and additional origin/top-origin validation; a top-level same-origin flow is the safest default.
POST /api/passkeys/register/options HTTP/1.1
Host: login.example.com
Cookie: session=AUTHENTICATED_SESSION
Content-Type: application/json
{"displayName":"Ari Example"}
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{"ceremonyId":"01JCE4Y7TZ4D3M4MNEHES2KYA3","publicKey":{"rp":{"id":"example.com","name":"Example"},"user":{"id":"AQIDBAUGBwg","name":"ari@example.com","displayName":"Ari Example"},"challenge":"z9MwdHskYjB3z7f2b7mGorQv-9NWB_8Ljd6Aq6s4nWA","pubKeyCredParams":[{"type":"public-key","alg":-7},{"type":"public-key","alg":-257}],"authenticatorSelection":{"residentKey":"required","userVerification":"required"},"attestation":"none"}}
POST /api/passkeys/register/verify HTTP/1.1
Host: login.example.com
Cookie: session=AUTHENTICATED_SESSION
Content-Type: application/json
{"ceremonyId":"01JCE4Y7TZ4D3M4MNEHES2KYA3","credential":{"type":"public-key","id":"BASE64URL_CREDENTIAL_ID","rawId":"BASE64URL_CREDENTIAL_ID","response":{"clientDataJSON":"BASE64URL_CLIENT_DATA","attestationObject":"BASE64URL_ATTESTATION"}}} Create a discoverable credential from server-authored registration options
PublicKeyCredentialCreationOptions contains a binary challenge, an opaque user handle of at most 64 bytes, RP identity, allowed COSE algorithms, duplicate-prevention descriptors, and authenticator preferences. For passkeys, request residentKey "required" and normally userVerification "required"; do not put an email address or other personally identifying data in user.id. Keep user.name and displayName current for account selection UI. excludeCredentials should contain that account's existing credential IDs, with stored transports when known. Algorithms must match what the server verifier and key store support. The Level 3 parser converts JSON base64url members to byte arrays; retain an encoding fallback while supporting older clients.
async function registerPasskey() {
if (!isSecureContext || !window.PublicKeyCredential) {
throw new Error('Passkeys are unavailable in this context');
}
const optionsResponse = await fetch('/api/passkeys/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ intent: 'add-passkey' }),
});
if (!optionsResponse.ok) throw new Error('Could not start registration');
const { ceremonyId, publicKey: publicKeyJSON } = await optionsResponse.json();
if (!PublicKeyCredential.parseCreationOptionsFromJSON) {
throw new Error('Use the documented base64url compatibility converter');
}
const controller = new AbortController();
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(publicKeyJSON);
const credential = await navigator.credentials.create({
publicKey,
signal: controller.signal,
});
if (!(credential instanceof PublicKeyCredential)) throw new Error('No credential returned');
const verifyResponse = await fetch('/api/passkeys/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ceremonyId, credential: credential.toJSON() }),
});
if (!verifyResponse.ok) throw new Error('The server rejected registration');
return verifyResponse.json();
} function buildRegistrationOptions({ challenge, userHandle, existingCredentials }) {
return {
challenge,
rp: { id: 'example.com', name: 'Example' },
user: {
id: userHandle,
name: 'ari@example.com',
displayName: 'Ari Example',
},
pubKeyCredParams: [
{ type: 'public-key', alg: -7 },
{ type: 'public-key', alg: -257 },
],
timeout: 300000,
excludeCredentials: existingCredentials.map(({ id, transports }) => ({
type: 'public-key',
id,
transports,
})),
authenticatorSelection: {
residentKey: 'required',
userVerification: 'required',
},
attestation: 'none',
};
} Encode every WebAuthn BufferSource as unpadded base64url
Challenges, user handles, credential IDs, clientDataJSON, authenticatorData, signatures, and attestation objects are bytes, not Unicode strings. JSON APIs conventionally represent them as unpadded base64url: replace plus with hyphen and slash with underscore, then remove equals padding. The WebAuthn Level 3 parseCreationOptionsFromJSON(), parseRequestOptionsFromJSON(), and credential.toJSON() methods perform the defined conversions. If compatibility code is required, enumerate Uint8Array bytes before btoa() and restore padding before atob(); do not run arbitrary binary data through TextDecoder or TextEncoder. Enforce payload-size limits on the server and compare decoded challenge bytes in constant time through the verifier library.
function bytesToBase64url(bytes) {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary)
.replaceAll('+', '-')
.replaceAll('/', '_')
.replace(/=+$/u, '');
}
function base64urlToBytes(value) {
if (!/^[A-Za-z0-9_-]*$/u.test(value)) throw new TypeError('Invalid base64url');
const padding = '='.repeat((4 - value.length % 4) % 4);
const binary = atob(value.replaceAll('-', '+').replaceAll('_', '/') + padding);
return Uint8Array.from(binary, character => character.charCodeAt(0));
}
function decodeRequestOptions(json) {
return {
...json,
challenge: base64urlToBytes(json.challenge),
allowCredentials: json.allowCredentials?.map(descriptor => ({
...descriptor,
id: base64urlToBytes(descriptor.id),
})),
};
} Verify registration completely before storing a credential record
On the server, retrieve the unexpired ceremony by its opaque identifier and authenticated account, then use a maintained WebAuthn verifier. It must decode clientDataJSON and require type webauthn.create, the exact issued challenge, an explicitly allowed origin, crossOrigin/topOrigin policy, and the expected RP ID hash. It must validate the attestation object's CBOR structure, user-presence and required user-verification flags, credential public key and algorithm, and the applicable attestation statement. After verification, atomically consume the challenge and store credential ID, public key, algorithm, user/account binding, RP ID, signature counter, backup flags when exposed, transports, and timestamps. Credential IDs are binary and should be uniquely indexed. The sample names a library-neutral verifier intentionally; implement these steps with a tested WebAuthn server package, not handwritten CBOR or signature code.
async function finishRegistration({ accountId, ceremonyId, response }) {
const ceremony = await ceremonyStore.requireActive({
id: ceremonyId,
accountId,
kind: 'registration',
});
const result = await webauthnVerifier.verifyRegistrationResponse({
response,
expectedChallenge: ceremony.challenge,
expectedOrigin: 'https://login.example.com',
expectedRPID: 'example.com',
requireUserVerification: true,
});
if (!result.verified || !result.registrationInfo) {
throw new Error('Registration verification failed');
}
const { credential, credentialDeviceType, credentialBackedUp } = result.registrationInfo;
await database.transaction(async transaction => {
await transaction.ceremonies.consume(ceremony.id);
await transaction.credentials.insert({
accountId,
userHandle: ceremony.userHandle,
credentialId: credential.id,
publicKey: credential.publicKey,
counter: credential.counter,
transports: response.response.transports ?? [],
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
rpId: 'example.com',
});
});
} Request either an account-scoped or discoverable authentication assertion
For a username-first flow, the server resolves the account before creating options and fills allowCredentials with only that account's credential descriptors. It must require the returned credential ID to belong to that account and, when userHandle is returned, require it to match. For a usernameless passkey flow, omit allowCredentials so the authenticator can discover RP-scoped credentials; userHandle is then required, and the server must require both it and the returned credential ID to resolve to the same stored account binding. In both cases, the server supplies a fresh challenge, RP ID, timeout, and userVerification policy. Submitting the assertion does not establish a session until server verification succeeds.
async function authenticateWithPasskey({ username } = {}) {
const optionsResponse = await fetch('/api/passkeys/authenticate/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username || null }),
});
if (!optionsResponse.ok) throw new Error('Could not start authentication');
const { ceremonyId, publicKey: publicKeyJSON } = await optionsResponse.json();
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(publicKeyJSON);
const controller = new AbortController();
const assertion = await navigator.credentials.get({
publicKey,
signal: controller.signal,
});
if (!(assertion instanceof PublicKeyCredential)) throw new Error('No assertion returned');
const verifyResponse = await fetch('/api/passkeys/authenticate/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ceremonyId, credential: assertion.toJSON() }),
});
if (!verifyResponse.ok) throw new Error('The server rejected authentication');
return verifyResponse.json();
} Verify the assertion and account binding before issuing an application session
The server must find an active authentication ceremony, resolve the credential record by the returned binary ID, and prevent cross-account substitution. A verifier must require clientDataJSON type webauthn.get, the exact one-time challenge, an allowed origin, the expected RP ID hash, user presence, and user verification when required. It then verifies the signature with the stored credential public key over authenticatorData concatenated with SHA-256(clientDataJSON). Treat a non-increasing nonzero signature counter as a risk signal rather than universal proof of cloning: some authenticators always return zero, and synced credentials or concurrent requests complicate counters. Consume the challenge, update counter and backup state consistently, then rotate or establish the application session. Rate limiting, audit logging, CSRF-safe session handling, and strong account recovery remain server responsibilities.
async function finishAuthentication({ ceremonyId, response }) {
const ceremony = await ceremonyStore.requireActive({
id: ceremonyId,
kind: 'authentication',
});
const stored = await credentialStore.findById(response.id);
if (!stored || stored.rpId !== ceremony.rpId) {
throw new Error('Authentication failed');
}
const returnedHandle = response.response.userHandle
? base64urlToBytes(response.response.userHandle)
: null;
if (ceremony.accountId && stored.accountId !== ceremony.accountId) {
throw new Error('Authentication failed');
}
if ((!ceremony.accountId && !returnedHandle) ||
(returnedHandle && !constantTimeEqual(returnedHandle, stored.userHandle))) {
throw new Error('Authentication failed');
}
const result = await webauthnVerifier.verifyAuthenticationResponse({
response,
expectedChallenge: ceremony.challenge,
expectedOrigin: 'https://login.example.com',
expectedRPID: ceremony.rpId,
credential: {
id: stored.credentialId,
publicKey: stored.publicKey,
counter: stored.counter,
transports: stored.transports,
},
requireUserVerification: true,
});
if (!result.verified) throw new Error('Authentication failed');
await database.transaction(async transaction => {
await transaction.ceremonies.consume(ceremony.id);
await transaction.credentials.updateState(stored.id, {
counter: result.authenticationInfo.newCounter,
backedUp: result.authenticationInfo.credentialBackedUp,
lastUsedAt: new Date(),
});
});
return sessionStore.createForAccount(stored.accountId);
} Integrate discoverable passkeys into form autofill with conditional mediation
Conditional mediation lets a pending navigator.credentials.get() participate in the browser's account chooser instead of immediately showing modal UI. First feature-detect isConditionalMediationAvailable(), mark an eligible input with an autocomplete token ending in webauthn, fetch usernameless request options with an empty or omitted allowCredentials list, and start one long-lived conditional get. The promise may remain pending until the user interacts with the field. Keep the password or explicit passkey button usable as a fallback, and abort the conditional request before starting a modal request to prevent competing ceremonies. Conditional UI does not reveal whether a credential exists and does not relax any server verification step.
<form id="sign-in" method="post">
<label for="username">Email or username</label>
<input id="username" name="username" autocomplete="username webauthn">
<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">Use a passkey</button>
</form> let conditionalController;
async function startConditionalPasskey() {
if (!PublicKeyCredential.isConditionalMediationAvailable) return null;
if (!await PublicKeyCredential.isConditionalMediationAvailable()) return null;
const optionsResponse = await fetch('/api/passkeys/authenticate/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: null, mediation: 'conditional' }),
});
if (!optionsResponse.ok) return null;
const { ceremonyId, publicKey: json } = await optionsResponse.json();
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(json);
conditionalController?.abort();
conditionalController = new AbortController();
const assertion = await navigator.credentials.get({
publicKey,
mediation: 'conditional',
signal: conditionalController.signal,
});
if (!assertion) return null;
return { ceremonyId, credential: assertion.toJSON() };
} Request attestation only for a defined trust policy and treat transports as hints
Attestation can disclose authenticator make, model, or trust-chain information and increases implementation and privacy cost. Keep attestation at none for ordinary consumer passkeys. Request direct or enterprise attestation only when a documented policy truly needs authenticator provenance, obtain appropriate user and organizational consent, and validate the returned format and trust path against maintained metadata; requesting attestation does not guarantee that an identifying statement will be returned. At registration, store response.getTransports() or the transports included by toJSON(). Reuse those values in later credential descriptors to help the client choose internal, usb, nfc, ble, smart-card, or hybrid routing, but never treat a transport string as authentication evidence or assume a synced credential remains on one transport.
function registrationPayload(credential, ceremonyId) {
const json = credential.toJSON();
return {
ceremonyId,
credential: {
...json,
response: {
...json.response,
transports: credential.response.getTransports(),
},
},
};
}
function descriptorFromRecord(record) {
return {
type: 'public-key',
id: record.credentialId,
transports: record.transports,
};
} Cancel superseded operations and keep errors privacy-neutral
Credential operations can wait on user interaction, an external security key, cross-device authentication, or platform UI. Supply an AbortSignal, cancel requests when their page state is obsolete, and prevent multiple simultaneous ceremonies. AbortError indicates application cancellation. NotAllowedError can cover denial, timeout, missing consent, or no usable credential; do not convert it into an account-enumeration message. InvalidStateError during registration can mean an excluded credential already exists. SecurityError often indicates an invalid RP ID, origin, context, or policy. NotSupportedError can indicate unsupported algorithms or capabilities. Log a correlation identifier and coarse outcome on the server, but do not log challenges, raw authenticator responses, session identifiers, or biometric claims. Offer retry and recovery without weakening the server's validation policy.
let activeController;
async function runCredentialRequest(publicKey, mediation = 'optional') {
activeController?.abort();
activeController = new AbortController();
try {
return await navigator.credentials.get({
publicKey,
mediation,
signal: activeController.signal,
});
} catch (error) {
if (!(error instanceof DOMException)) throw error;
if (error.name === 'AbortError') return null;
if (error.name === 'NotAllowedError') {
showMessage('Passkey sign-in was not completed. Try again or use recovery.');
return null;
}
if (error.name === 'SecurityError' || error.name === 'NotSupportedError') {
showMessage('Passkeys are unavailable here. Use another sign-in method.');
return null;
}
throw error;
} finally {
activeController = undefined;
}
}
function cancelCredentialRequest() {
activeController?.abort(new DOMException('Request cancelled', 'AbortError'));
} Sources and further reading
References
Authoritative documentation used to verify and expand this cheat sheet.
- World Wide Web ConsortiumWeb Authentication: An API for Accessing Public Key Credentials — Level 3w3.org
- World Wide Web ConsortiumCredential Management Level 1w3.org
- World Wide Web ConsortiumSecure Contextsw3.org
- WHATWGHTML Standard: Autofillhtml.spec.whatwg.org
- FIDO AllianceFIDO Passkeysfidoalliance.org
- FIDO AllianceClient to Authenticator Protocol 2.3fidoalliance.org
Help us improve
Found a typo or missing example?
Tell us what would make this cheat sheet clearer, more complete, or more useful.



