Skip to content
ecdsa.com
← All articles

Passkeys for developers: WebAuthn without the guesswork

A passkey is a WebAuthn credential the user can sign in with without typing a username or password. The browser API is small; the decisions around it are not. Here are both ceremonies end to end, what the server must check, and the exact errors Chromium raises, all from a run against a virtual authenticator.

Short answer. A passkey is a discoverable WebAuthn credential: a key pair created by an authenticator (a phone, a laptop's secure hardware, a password manager or a security key) for one site, the relying party. Registration sends the server a public key; sign-in sends a signature over a server challenge. The server must check the challenge, the origin, the RP ID and the user-verification flag on every response. Ask for residentKey: "required" and userVerification: "required", offer -8, -7 and -257 as algorithms, leave attestation at "none" unless you are an enterprise that restricts authenticator models, and expect most passkeys to be synced between devices.

The model in one table

TermWhat it means for your code
Relying party (RP) and RP IDYour site. The RP ID is a domain, such as example.com; credentials work on that domain and its subdomains, never elsewhere
Discoverable credentialStored on the authenticator together with the user handle, so sign-in needs no username. WebAuthn Level 3 lists "passkey" as another name for it
User handle (user.id)Your opaque account ID, at most 64 bytes, returned at sign-in. Never an email address
User presence (UP) and user verification (UV)UP: someone touched or confirmed. UV: the authenticator checked a PIN or biometric. Flags in the authenticator data
Backup eligible (BE) and backed up (BS)A credential that may be, or is, synced to other devices: a synced passkey, or "multi-device credential" in the spec
Attestation and AAGUIDAn optional certificate chain saying which authenticator model made the key; the AAGUID identifies the model

The authenticator data flags are bit 0 UP, bit 2 UV, bit 3 BE, bit 4 BS, bit 6 AT (attested credential data included) and bit 7 ED (extensions) (WebAuthn Level 3 §6.1). WebAuthn Level 3 became a W3C Recommendation on 25 August 2026.

Server: both ceremonies

This is a complete relying party with @simplewebauthn/server 14.0.3. Storage is in memory to keep it short; in production the three maps are database tables and the challenge lives in the user's server-side session.

server.mjs (@simplewebauthn/server 14.0.3)
// Relying party: registration and sign-in ceremonies with @simplewebauthn/server 14.0.3.
import {
  generateAuthenticationOptions,
  generateRegistrationOptions,
  verifyAuthenticationResponse,
  verifyRegistrationResponse,
} from "@simplewebauthn/server";

const rpID = "localhost"; // the site's registrable domain in production, e.g. "example.com"
const origin = "http://localhost:3000"; // every origin allowed to use this RP ID
const users = new Map(); // userName -> { id, credentials: [] }
const credentials = new Map(); // credential ID -> { userName, publicKey, counter, transports }
const pending = new Map(); // ceremony ID -> expected challenge, single use

export async function registrationOptions(userName, { attestation = "none" } = {}) {
  const user = users.get(userName) ?? { id: crypto.getRandomValues(new Uint8Array(16)), credentials: [] };
  users.set(userName, user);
  const options = await generateRegistrationOptions({
    rpName: "Example",
    rpID,
    userName,
    userID: user.id,
    attestationType: attestation,
    authenticatorSelection: { residentKey: "required", userVerification: "required" },
    supportedAlgorithmIDs: [-8, -7, -257], // EdDSA, ES256, RS256
    excludeCredentials: user.credentials.map((id) => ({ id, transports: credentials.get(id).transports })),
  });
  pending.set(`reg:${userName}`, options.challenge);
  return options;
}

export async function finishRegistration(userName, response) {
  const expectedChallenge = pending.get(`reg:${userName}`);
  pending.delete(`reg:${userName}`);
  const { verified, registrationInfo: info } = await verifyRegistrationResponse({
    response,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    requireUserVerification: true,
  });
  if (!verified) throw new Error("registration not verified");
  const { credential } = info;
  credentials.set(credential.id, { userName, publicKey: credential.publicKey, counter: credential.counter, transports: credential.transports });
  users.get(userName).credentials.push(credential.id);
  return info;
}

export async function authenticationOptions() {
  // Empty allowCredentials: the authenticator offers its discoverable credentials (passkeys).
  const options = await generateAuthenticationOptions({ rpID, userVerification: "required" });
  pending.set(`auth:${options.challenge}`, options.challenge);
  return options;
}

export async function finishAuthentication(response, challenge) {
  if (!pending.delete(`auth:${challenge}`)) throw new Error("unknown or used challenge");
  const stored = credentials.get(response.id);
  if (!stored) throw new Error("unknown credential");
  const { verified, authenticationInfo } = await verifyAuthenticationResponse({
    response,
    expectedChallenge: challenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: { id: response.id, publicKey: stored.publicKey, counter: stored.counter, transports: stored.transports },
    requireUserVerification: true,
  });
  if (!verified) throw new Error("authentication not verified");
  stored.counter = authenticationInfo.newCounter;
  return { userName: stored.userName, ...authenticationInfo };
}

What the verify calls check, and why each matters:

  • Challenge. Random, single use, bound to this ceremony. A response with an old challenge is a replay.
  • Origin. The browser writes the page's origin into clientDataJSON, and the authenticator signs a hash of it. Comparing it with your list of origins is what makes passkeys phishing-resistant: a look-alike site produces a response with its own origin.
  • RP ID hash. The authenticator data starts with SHA-256 of the RP ID; a credential for another site cannot be used for yours.
  • Flags. UP must be set; UV must be set when you rely on the passkey alone, which is the point of a passkey.
  • Signature over the authenticator data and the client data hash, with the public key stored at registration.
  • Counter. Many synced passkeys always report 0. WebAuthn treats a decreasing counter as a possible clone only when a non-zero value is involved (§6.1.1); never reject zero.

Browser: two calls

browser.js
// Browser side of both ceremonies. JSON options from the server need their base64url
// fields turned into ArrayBuffers; the static helpers on PublicKeyCredential do that.
async function register(optionsJSON) {
  const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJSON);
  const credential = await navigator.credentials.create({ publicKey });
  return credential.toJSON(); // send to the server as-is
}

async function signIn(optionsJSON) {
  const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);
  const credential = await navigator.credentials.get({ publicKey });
  return credential.toJSON();
}

parseCreationOptionsFromJSON, parseRequestOptionsFromJSON and toJSON are part of WebAuthn Level 3 and replace the base64url conversion code every project used to carry. For a username field that offers passkeys in the browser's autofill, start navigator.credentials.get() with mediation: "conditional" when the page loads, after checking PublicKeyCredential.isConditionalMediationAvailable(), and mark the input with autocomplete="username webauthn".

Run end to end

The lab drove Chromium with Playwright 1.64.1 (mcr.microsoft.com/playwright:v1.64.1-noble) and a virtual authenticator added through the Chrome DevTools Protocol. The page on http://localhost:3000 ran browser.js; the driver passed the options from server.mjs in and the responses out. Every line comes from a real registration or sign-in:

Output (Chromium 156.0.8078.4, Node.js in the Playwright image)
chromium 156.0.8078.4, virtual platform authenticator with user verification
registration options: challenge 43 chars, pubKeyCredParams -8, -7, -257, attestation none
registered: fmt none, COSE alg -8, transports internal, credentialDeviceType singleDevice, backedUp false, userVerified true, aaguid 01020304-0506-0708-0102-030405060708
second registration on the same authenticator: InvalidStateError: The user attempted to register an authenticator that contains one of the credentials already registered with the relying party.
signed in as alice: userVerified true, newCounter 2, credentialBackedUp false
replayed assertion: unknown or used challenge
response created on http://localhost:4000: Unexpected registration response origin "http://localhost:4000", expected "http://localhost:3000"
synced passkey, attestation "direct": fmt packed, credentialDeviceType multiDevice, backedUp true
sign-in when the authenticator cannot verify the user: NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.

What each line demonstrates:

  • Algorithms. The options offered -8, -7 and -257; the authenticator chose -8 (EdDSA), the first one it supports. Order expresses preference.
  • excludeCredentials works, with the InvalidStateError that tells your UI "this device already has a passkey for this account".
  • Sign-in without a username. allowCredentials was empty; the authenticator offered its discoverable credential and the server found the account through the credential ID and its stored public key.
  • Replays fail at the challenge. Deleting the challenge when it is used is what makes the second attempt fail.
  • The browser does not block every wrong origin. A page on http://localhost:4000 could create a credential for RP ID localhost, because the RP ID covers every port and subdomain of that domain. Only the server's origin check rejected it: Unexpected registration response origin. Keep expectedOrigin an exact list.
  • Synced passkeys announce themselves. With backup eligibility on, SimpleWebAuthn reported multiDevice and backedUp true.
  • UV required is enforced by the browser. With an authenticator that could not verify the user, the browser refused with the generic NotAllowedError; the server-side requireUserVerification is the second line.

Rolling out passkeys next to passwords

Few sites can switch every account at once. WebAuthn Level 3 adds the pieces for a gradual rollout, all discoverable through PublicKeyCredential.getClientCapabilities():

  • Offer a passkey right after a password sign-in. With the conditionalCreate capability, navigator.credentials.create() with mediation: "conditional" lets the password manager that just filled the password create a passkey without an extra dialog.
  • Sign in from the username field. The conditionalGet capability is the autofill flow described above; users with a passkey pick it, everyone else types a password as before.
  • Keep the authenticator's list in sync with yours. When a user deletes a passkey in your account settings, the authenticator still shows it. signalUnknownCredential() tells it that a credential ID is no longer accepted, signalAllAcceptedCredentials() sends the full list for a signed-in user, and signalCurrentUserDetails() updates the name shown after a user renames their account (§5.1.10).
  • Several domains, one RP ID. A brand that signs in on example.com and example.co.uk can keep one RP ID and list the other origins in https://example.com/.well-known/webauthn as {"origins": [...]}. The browser fetches that file when the RP ID is not the page's own domain (related origin requests, §5.11); if the fetch fails, the result is the SecurityError shown below.
  • Record BE and BS per credential. A user whose only passkey is single-device and not backed up will lose access with that device; prompt them to add a second one before you take their password away.

Attestation: when you need it

Attestation answers "which authenticator model made this key". It does not say anything about the user, the device's patch level or whether it is rooted. With attestation: "none", the default, the browser replaces any attestation statement with an empty one before your server sees it. The lab still received the authenticator's AAGUID: WebAuthn Level 2 told browsers to replace it with 16 zero bytes in this case, and Level 3 dropped that step. Without attestation the AAGUID is a claim, not a proof.

Do not require attestation on a consumer site. Synced passkeys are meant to follow the user to any device, and their providers have generally not attested them: Google said when it launched passkeys on Android that they "will not initially have any attestation" (fido-dev list, 2022). The lab's virtual authenticator, asked for "direct", returned a packed statement, which shows what an attesting authenticator sends but not what a particular sync provider does, so test the providers your users actually have. Request it when you must restrict registration to a set of models, such as security keys issued by your company, and verify the chain against the FIDO Metadata Service. To show users a friendly name for their passkey provider ("Google Password Manager", "1Password"), the community-maintained passkey authenticator AAGUID list maps AAGUIDs to names; its own README says it is for display only and not a source of security decisions.

COSE algorithms

pubKeyCredParams lists COSE algorithm identifiers (IANA COSE registry). The spec's own example accepts EdDSA, ES256 and RS256 and prefers EdDSA; that list covers practically every authenticator in use:

ValueNameNotes
-8EdDSAEd25519 only in WebAuthn (§5.8.5); marked deprecated in the COSE registry by RFC 9864 in favour of -19 Ed25519, but authenticators still emit -8
-7ES256ECDSA P-256; the most widely supported. Deprecated in COSE in favour of -9 ESP256, same situation
-257RS256RSA PKCS#1 v1.5; Windows 10 and some Windows 11 installations of Windows Hello only create RS256 credentials (Yubico on the W3C list)
-48, -49, -50ML-DSA-44, -65, -87Registered by RFC 9964 (May 2026). Chromium 156's virtual authenticator accepted -49 (below); check real authenticators and your server library before relying on them

Keep -7 and -257 in the list even if you prefer -8: the spec's own default when pubKeyCredParams is empty is ES256 and RS256. Store the algorithm with the credential; SimpleWebAuthn keeps it inside the stored COSE public key.

The DOMException errors

navigator.credentials.create() and get() report failures through a small set of exceptions, listed in WebAuthn Level 3 §5.1.3.1 and §5.1.4.3. What Chromium 156 raised for common mistakes, against a virtual USB security key without discoverable-credential storage:

Output: errors.mjs (Chromium 156.0.8078.4)
only an unknown algorithm (-65000)           NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
only ML-DSA-65 (-49)                         created, COSE alg -49
residentKey required, key cannot store it    NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
rp.id "example.com" on localhost             SecurityError: The relying party ID is not a registrable domain suffix of, nor equal to the current domain. Subsequently, an attempt to fetch the .well-known/webauthn resource of the claimed RP ID failed.
user.id of 65 bytes                          TypeError: User handle exceeds 64 bytes.
aborted with AbortController                 AbortError: signal is aborted without reason
ExceptionMeaning per the specWhat to do
NotAllowedErrorCatch-all: user cancelled, timeout, no matching credential, requirements no authenticator metTreat as "did not complete"; offer retry and another method. Never show the raw message
InvalidStateErrorAn excludeCredentials entry was recognizedTell the user this device already has a passkey here
SecurityErrorRP ID is not the page's domain or a registrable suffix of it, or related origins validation failedFix configuration; see the SecurityError page
NotSupportedErrorNo usable algorithm in pubKeyCredParamsOffer -7 and -257
ConstraintErrorresidentKey or userVerification required and no authenticator capableSame handling as NotAllowedError in practice
AbortErrorYour AbortController cancelled the callExpected when switching from autofill to a modal prompt
TypeErrorMalformed options, for example a user handle over 64 bytesBug in your options

Two of the lab's results differ from the spec's list: Chromium answered NotAllowedError, not NotSupportedError, when the only algorithm was unknown, and NotAllowedError, not ConstraintError, when a discoverable credential was required from a key that cannot store one. Code that branches on NotSupportedError or ConstraintError to explain the problem to users will never see those in Chromium for these cases. Both APIs exist only in secure contexts. On a plain-HTTP page that is not localhost, Chromium 156 reported isSecureContext: false with navigator.credentials, PublicKeyCredential and crypto.subtle all undefined, so code fails with a TypeError before any ceremony starts (the Web Crypto variant is crypto.subtle is undefined). Serve every page that uses passkeys over HTTPS; http://localhost is the one exception, for development.

Checklist

  1. RP ID set to the registrable domain your users see; expectedOrigin an explicit list of HTTPS origins, including every subdomain that signs in.
  2. residentKey: "required", userVerification: "required", server checks UV on every response.
  3. pubKeyCredParams with -8, -7, -257; excludeCredentials filled with the user's existing credentials.
  4. Challenges random, single use, short lived, stored server side.
  5. Credentials stored with ID, public key, counter, transports, BE and BS flags, AAGUID and a user-editable name; users can register several and delete them.
  6. Attestation "none" unless you restrict authenticator models; then verify against FIDO MDS.
  7. Account recovery that is not weaker than the passkey itself: a second passkey, or a verified recovery path, not an SMS code that bypasses everything.

Sources