Adding passkeys to a Node.js app is four HTTP endpoints and one database table. Two endpoints hand the browser a challenge, two verify what comes back, and the table holds a public key per credential. The library part takes an afternoon. The credential lifecycle around it, which nobody demos, is what locks users out three months later.
The library most Node apps reach for is SimpleWebAuthn, @simplewebauthn/server 13.3.3 with @simplewebauthn/browser 13.3.0 as of August 2026. It needs Node 20 or newer, and nothing in it is exotic: plain functions over Web Crypto, so it runs happily in a service written in TypeScript with no loader. Everything below assumes those two packages, but the traps are in the Web Authentication API itself, so they apply whichever library you pick.
What the server has to store for each passkey
A passkey is a public key plus some metadata. verifyRegistrationResponse() hands you a WebAuthnCredential with exactly four fields, id (a base64url string), publicKey (a Uint8Array, so BYTEA or BLOB in the database), counter, and an optional transports array. Alongside it you get credentialDeviceType, either 'singleDevice' or 'multiDevice', and credentialBackedUp. Store both. They tell you whether the user has one key stuck on one laptop or a synced credential that survives a lost phone, and that changes what your account recovery has to do.
The field that bites is the WebAuthn user ID, the user handle sent at registration. generateRegistrationOptions() takes an optional userID and, if you leave it out, generates a random one. Do that twice for the same person and their two passkeys carry two different handles, so no provider can group them and the Signal API further down has nothing stable to address. Generate it once, store it, reuse it forever:
import { generateUserID, isoBase64URL } from '@simplewebauthn/server/helpers';
// once, when the user account is created
const webauthnUserID = isoBase64URL.fromBuffer(await generateUserID());
Registering a passkey
Two endpoints. The first builds the options and stashes the challenge in the session, the second verifies the response and writes the credential.
import {
generateRegistrationOptions,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';
const rpID = 'example.com';
const origin = `https://${rpID}`;
app.post('/passkeys/register/options', async (req, res) => {
const user = await getUser(req.session.userId);
const options = await generateRegistrationOptions({
rpName: 'Example',
rpID,
userName: user.email,
userDisplayName: user.name,
userID: isoBase64URL.toBuffer(user.webauthnUserID),
excludeCredentials: user.credentials.map((c) => ({
id: c.id,
transports: c.transports,
})),
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred',
},
});
req.session.challenge = options.challenge;
res.json(options);
});
app.post('/passkeys/register/verify', async (req, res) => {
const user = await getUser(req.session.userId);
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: req.session.challenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
if (!verification.verified) {
return res.status(400).json({ error: 'registration failed' });
}
const { credential, credentialDeviceType, credentialBackedUp } =
verification.registrationInfo;
await saveCredential({
userId: user.id,
id: credential.id,
publicKey: credential.publicKey,
counter: credential.counter,
transports: credential.transports,
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
});
res.json({ ok: true });
});
Three defaults worth knowing. attestationType defaults to 'none', which is right for consumer sign-in, because attestation only matters if you are filtering authenticator models. supportedAlgorithmIDs defaults to [-8, -7, -257], Ed25519 plus ES256 plus RS256. And requireUserVerification on the verify call defaults to true, which is stricter than the 'preferred' you asked for in the options, so a device that skipped the biometric will register fine in the browser and then fail on your server. Pick one policy and set it on both sides.
excludeCredentials is what stops a user registering the same authenticator twice. When it fires, the browser throws and @simplewebauthn/browser surfaces it as a WebAuthnError with code 'ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED'. Catch that specific code and say “you already have a passkey on this device” instead of “something went wrong”.
Wiring passkeys into a Node.js login form
Discoverable credentials mean the login endpoint does not need to know who is signing in. Call generateAuthenticationOptions({ rpID }) with no allowCredentials at all and the browser offers whatever it holds for that RP ID.
On the client, conditional UI puts those passkeys in the autofill dropdown rather than behind a button. It needs an input annotated with both tokens, per Google’s autofill guide:
<input name="username" autocomplete="username webauthn" autofocus>
import { startAuthentication, WebAuthnError } from '@simplewebauthn/browser';
const optionsJSON = await fetch('/passkeys/login/options').then((r) => r.json());
try {
const response = await startAuthentication({
optionsJSON,
useBrowserAutofill: true,
});
await fetch('/passkeys/login/verify', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(response),
});
} catch (err) {
// the pending request is aborted whenever another ceremony starts
const aborted = err instanceof WebAuthnError &&
err.code === 'ERROR_CEREMONY_ABORTED';
if (!aborted) throw err;
}
That call sets mediation: 'conditional' and then sits there, pending, showing no UI of its own until the user touches the field. Only one WebAuthn ceremony can be active at a time, and @simplewebauthn/browser enforces that for you: every startAuthentication() or startRegistration() call installs a fresh abort signal, so pressing a “Sign in with a passkey” button rejects the pending conditional request with 'ERROR_CEREMONY_ABORTED'. That is why the catch block above swallows exactly that code rather than reporting it. On a client-side router, cancel the pending request on each route change with WebAuthnAbortService.cancelCeremony().
Check support before you commit the form to autofill. browserSupportsWebAuthnAutofill() resolves to a boolean, and the underlying PublicKeyCredential.isConditionalMediationAvailable() landed in Chrome 108, Firefox 119 and Safari 16, so a plain button is the fallback for anything older.
The verify endpoint looks the credential up by response.id, hands it to verifyAuthenticationResponse() as credential, and writes authenticationInfo.newCounter back to that row. When no row matches the ID, resist the urge to return a generic 500. That case has its own fix, further down.
Why the signature counter will not catch a cloned passkey
You will store counter, dutifully compare it, and find it sitting at zero. The spec is blunt about why that is allowed: “Authenticators that do not implement a signature counter leave the signCount in the authenticator data constant at zero.” A counter is per authenticator, and a synced credential exists on several at once, so implementing one usefully is not really on the table for the providers most of your users have.
Clone detection through the counter therefore describes hardware security keys, not the credential a typical user creates. The spec’s own guidance is to compare only when either value is non-zero, and to treat a mismatch as a risk signal rather than proof, since a race between two assertions produces the same symptom. Keep writing newCounter back, log an anomaly, and put the real defences elsewhere: rate limiting, session binding, and re-authentication before anything sensitive. Worth covering that verification path with tests too, because the failure mode here is silent.
Telling the passkey provider when you delete a credential
Here is the failure that generates support tickets. A user deletes a passkey in your account settings. Their password manager still shows it, still offers it at sign-in, and the sign-in fails with no useful explanation. Nothing in the original WebAuthn design told the provider that the server had moved on.
The Signal API closes that gap with three static methods on PublicKeyCredential. Call signalAllAcceptedCredentials() after any deletion, and any passkey missing from the list gets hidden by the provider:
if ('signalAllAcceptedCredentials' in PublicKeyCredential) {
await PublicKeyCredential.signalAllAcceptedCredentials({
rpId: 'example.com',
userId: webauthnUserID,
allAcceptedCredentialIds: remainingCredentialIds,
});
}
signalUnknownCredential({ rpId, credentialId }) handles the case where a sign-in arrives for a credential you have never seen, and signalCurrentUserDetails({ rpId, userId, name, displayName }) pushes a changed email or display name so the provider stops showing the old one. All three shipped in Chrome and Edge 132 and Safari 26. Firefox has none of them as of August 2026 (per browser-compat-data 8.0.12), so feature detect and treat it as a nicety.
Sharing one passkey across example.com and example.co.uk
Passkeys are scoped to an RP ID, which normally has to match the origin’s domain. Run the same product on example.com, example.co.uk and example.de and each gets its own passkeys unless you opt into related origin requests. Pick one RP ID, then serve a JSON document at the well-known URL for it, https://example.com/.well-known/webauthn, with content type application/json:
{
"origins": [
"https://example.co.uk",
"https://example.de",
"https://exampledelivery.com"
]
}
The spec requires clients to support at least five registrable origin labels, and client policy sets the upper bound, so treat five distinct labels as the ceiling. Multiple origins under one label (example.com and example.co.uk share the label example) count once. The fetch happens without credentials and without a referrer, over HTTPS only, which rules out putting the file behind auth or a redirect to HTTP.
What I would ship
Passkeys as an additional credential on an existing account, not as the only one. Registration prompted after a successful login rather than during signup, residentKey: 'required' so discoverable login works, conditional UI on the login form with a button as fallback, the Signal API called on every credential change, and passwords or email codes kept alive as recovery until your own numbers say most active users have two passkeys on separate devices.
Where I would not start with passkeys: shared workstations, where a synced credential on a personal phone is the wrong shape for a shift worker signing into a till; anything with a regulated identity-proofing step that passkeys do not satisfy on their own; and any product that cannot yet answer “what happens when the user loses the device”. On the membership and login systems we build, that one question decides more of the design than the WebAuthn code does, and it lands long before any of the code above.