Building the Passkey Registration Ceremony
You wired up navigator.credentials.create() and the browser returns an attestation, but the server keeps rejecting it — or worse, accepts it and stores nothing usable for later sign-in. This page is part of the passkeys and WebAuthn walkthrough, and it dissects the server side of registration: generateRegistrationOptions and verifyRegistrationResponse from @simplewebauthn/server, why each option exists at the spec level, and exactly what you must persist so authentication works later.
Root cause: registration is a contract, not a single call
The WebAuthn registration ceremony (a W3C Recommendation built on FIDO2/CTAP2) is a two-round-trip handshake. Round one, the server issues options — including a fresh challenge — describing what kind of credential it wants. The authenticator creates a key pair, signs the challenge, and returns an attestation object. Round two, the server verifies that attestation against the challenge it issued, then stores the public key. Every option in round one exists to constrain that key pair or to defend round two. Skip the reasoning and you get credentials that can’t be addressed, can’t be verified, or silently accept replays.
generateRegistrationOptions, option by option
import { generateRegistrationOptions } from "@simplewebauthn/server";
const options = await generateRegistrationOptions({
rpName: "Example App",
rpID: "app.example.com",
userName: user.email,
userID: new TextEncoder().encode(user.id), // stable, non-PII bytes
attestationType: "none",
excludeCredentials: existingCreds.map((c) => ({
id: c.credentialID,
transports: c.transports,
})),
authenticatorSelection: {
residentKey: "required",
userVerification: "preferred",
},
supportedAlgorithmIDs: [-7, -257], // ES256, RS256
timeout: 60_000,
});
rpID scopes the credential to your registrable domain. The authenticator binds the key pair to this value, and the browser will only ever offer the credential back on a matching origin — this is the root of WebAuthn’s phishing resistance. It must be the registrable suffix of the serving origin; an arbitrary string throws The RP ID is not a registrable domain suffix.
userID is an opaque handle (up to 64 bytes), not an email or sequential integer. The authenticator uses it to group credentials for the same account, and for synced passkeys it becomes the key under which the credential is stored in the user’s manager. Use a random, stable per-account value — never rotate it, or the user gets duplicate passkey entries.
attestationType: "none" tells the authenticator not to send a verifiable attestation statement about its make and model. For consumer passkeys this is the right default: requesting "direct" attestation collects manufacturer data you usually have no policy use for, harms privacy, and breaks synced passkeys that don’t attest. Only request attestation when an enterprise policy actually allowlists specific authenticator models, and verify it against the FIDO Metadata Service if you do.
excludeCredentials lists the credential IDs this user already registered. The authenticator refuses to create a second key pair on a device that already holds one for this account, so the user doesn’t end up with redundant passkeys and you get a clean “you’ve already set this up here” signal instead of a duplicate. Omitting it is a usability bug, not a security hole — but a common one.
residentKey: "required" is what makes this a passkey rather than a legacy second-factor credential. A resident (discoverable) credential stores enough state on the authenticator that the user can later sign in without first typing a username — the authenticator presents the credential itself. Set it to "discouraged" and you get a non-discoverable credential that requires allowCredentials at login, defeating usernameless flows.
userVerification: "preferred" asks the authenticator to verify the human (biometric or PIN) when it can, without hard-failing devices that can’t. Use "required" only when every supported authenticator does UV, or you’ll lock out some security keys.
supportedAlgorithmIDs is an explicit allowlist of COSE algorithm identifiers: -7 is ES256 (ECDSA P-256), -257 is RS256. Pinning these refuses any curve or algorithm you haven’t vetted. There is no alg: none escape hatch in WebAuthn, but an over-broad list lets weak or experimental algorithms into your credential store, so keep it tight.
Storing the challenge: the part everyone gets wrong
The challenge is a server-generated random value that must be single-use and bound to this specific user’s pending ceremony. The authenticator signs it; round two checks that the signed challenge equals the one you issued. Store it server-side — in the session or a short-TTL store — never in a hidden form field the client could replay or swap.
await sessionStore.set(req.sessionId, {
webauthnChallenge: options.challenge, // base64url string
challengeUserId: user.id,
expiresAt: Date.now() + 60_000,
});
res.json(options);
Two failure modes dominate here. Storing the challenge in a global variable (or a single shared key) means concurrent registrations clobber each other and verification fails intermittently under load. Not storing it at all — and trusting a challenge echoed back by the client — removes the replay protection entirely, since an attacker can resubmit a captured attestation.
verifyRegistrationResponse and what to persist
import { verifyRegistrationResponse } from "@simplewebauthn/server";
const stored = await sessionStore.get(req.sessionId);
if (!stored?.webauthnChallenge || stored.expiresAt < Date.now()) {
return res.status(400).json({ error: "challenge expired or missing" });
}
const verification = await verifyRegistrationResponse({
response: req.body, // the attestation from the browser
expectedChallenge: stored.webauthnChallenge,
expectedOrigin: "https://app.example.com",
expectedRPID: "app.example.com",
requireUserVerification: false,
});
if (!verification.verified || !verification.registrationInfo) {
return res.status(400).json({ error: "registration verification failed" });
}
const { credential, credentialDeviceType, credentialBackedUp } =
verification.registrationInfo;
await db.saveCredential({
userId: stored.challengeUserId,
credentialID: credential.id, // base64url; primary lookup key
publicKey: credential.publicKey, // raw COSE bytes -> bytea/blob
counter: credential.counter, // signature counter baseline
transports: req.body.response.transports ?? [],
deviceType: credentialDeviceType, // "singleDevice" | "multiDevice"
backedUp: credentialBackedUp, // is it a synced passkey?
});
await sessionStore.delete(req.sessionId); // burn the challenge
You must persist three things or authentication is impossible:
credentialID— the address you use inallowCredentialsand to look up the credential when an assertion arrives. Index it.publicKey— raw COSE-encoded bytes. This verifies every future assertion signature. Store it as binary, not as a re-encoded JSON blob.counter— the signature counter baseline for clone detection during authentication. Many synced passkeys report0; persist whatever the authenticator gives.
Persisting transports, deviceType, and backedUp is optional but valuable: transports hint the browser at the right interface (internal, usb, hybrid), and backedUp tells you whether the passkey is synced — useful when deciding whether to nudge the user to enroll a second credential for account recovery.
Security implications
Origin and RP ID checks in verifyRegistrationResponse are not optional hardening — they are the ceremony’s integrity. expectedOrigin must match the browser’s reported origin byte-for-byte (scheme, host, and port), and expectedRPID must match the value you issued. A mismatch means the attestation was produced for a different site, and the call throws. The challenge check closes replay: because you delete the challenge after a successful verify, a captured attestation cannot be resubmitted. Finally, because only the public key reaches your database, a full database compromise leaks nothing an attacker can authenticate with — the private key stayed on the authenticator the entire time.
Prevention & monitoring
- Alert on verification failures by reason. A spike in origin or RP ID mismatches usually means a config drift between environments, not an attack — but a spike in challenge-mismatch failures can indicate replay attempts.
- Log device type and backup state at enrollment so you can report how many users have only a single-device, non-synced passkey — those are the accounts most likely to need recovery.
- Enforce challenge TTL and single use in the store itself (short expiry plus delete-on-verify) rather than relying on application code paths alone.
Frequently Asked Questions
Why store the public key as raw bytes instead of JSON?
@simplewebauthn/server hands you the public key as COSE-encoded bytes and expects the same bytes back at authentication time. Re-encoding it through JSON.stringify or a custom format risks lossy round-trips and signature-verification failures. Persist it as bytea/BLOB (or a base64url string you decode losslessly) and pass it through unchanged.
What happens if I skip excludeCredentials?
The user can register a second passkey on a device that already has one, producing duplicate credentials for the same account. It’s not a security vulnerability, but it clutters their authenticator and your credentials table, and it removes the clean “already registered here” UX signal. Always populate it from the user’s existing credentials.
Should I request direct attestation to verify the authenticator model?
Only if an enterprise policy genuinely requires allowlisting specific authenticator makes and models, in which case you must validate the attestation statement against the FIDO Metadata Service. For consumer passkeys use attestationType: "none" — "direct" collects identifying hardware data you rarely use, harms user privacy, and breaks many synced passkeys that don’t attest.
The counter comes back as 0 — is registration broken?
No. Many platform and synced-passkey authenticators report a signature counter of 0 and never increment it, because a synced credential exists on multiple devices simultaneously and a per-device counter would be meaningless. Store the 0 baseline. The clone-detection logic at authentication treats a zero counter as “not supported” rather than as an error.
Can the same user register passkeys on multiple devices?
Yes, and you should encourage it. Each device produces a distinct credential ID and public key tied to the same userID. Storing several credentials per account is the simplest, strongest recovery strategy — losing one device still leaves a working passkey. The registration verify endpoint already supports this; just don’t list a device’s existing credential in excludeCredentials if you want it to enroll an additional one for a different account.
Related
- Implementing passkeys and WebAuthn — the full ceremony model and client glue this page fits into.
- Verifying passkey authentication assertions — how the stored credentialID, publicKey, and counter are used at sign-in.
- Passkey account recovery and fallback strategies — why enrolling multiple credentials per account is your best recovery plan.