The pressure to ship passkeys went from “nice roadmap item” to “the CISO asked about it on Monday” sometime in the last year. Microsoft now makes brand-new accounts passwordless by default, registers close to a million passkeys a day, and reports a 98% sign-in success rate for passkey users versus 32% for password accounts. The FIDO Alliance counts 1.3 billion passkey authentications a month, roughly double a year ago. Cyber insurers and SOC 2 auditors have started asking the magic words: “phishing-resistant authentication.”
So now it’s your problem. And here’s the thing nobody tells you in the vendor decks: the WebAuthn ceremony itself is the easy part. You can wire up registration and login in an afternoon. The part that eats two sprints and a few production incidents is everything around it — getting the RP ID right, designing account recovery that doesn’t quietly reintroduce the phishing hole you just closed, and explaining to a confused user why the passkey they made on their old laptop won’t work on the new one.
This is the playbook I wish I’d had. It’s Node-flavored (using @simplewebauthn/server, currently on v13), but the decisions translate to any language.
The four things a rollout actually lives or dies on
Before any code, internalize these. They’re the difference between a passkey launch that hits 60% adoption and one that generates support tickets for a month.
- RP ID and origin correctness. Get these wrong and registration silently fails on some devices, or — worse — works in dev and breaks in prod.
- Challenge handling. The challenge is your replay protection. Store it server-side, scope it to the session, expire it fast.
- Account recovery. This is where most of the security thinking goes, not the ceremony. A weak recovery path makes the whole passkey investment cosmetic.
- The OS dialog copy and UX. Users don’t read “WebAuthn.” They see a system prompt asking for their fingerprint and panic if your surrounding UI didn’t prime them.
Three of those four are operational, not cryptographic. Keep that ratio in mind when you’re estimating the work.
RP ID and origin: the part that breaks in production
The Relying Party ID is the single most common source of “it worked locally” bugs. The RP ID is your site’s domain — example.com — without scheme or port. The browser enforces that the RP ID is either equal to the current origin’s domain or a registrable suffix of it.
What that means in practice:
- If users log in at
app.example.com, you can set RP ID to eitherapp.example.comorexample.com. - Set it to
example.comand a passkey created atapp.example.comwill also work ataccount.example.comandwww.example.com. That’s usually what you want. - Set it to
app.example.comand the passkey is locked to that exact subdomain. Migrate the app to a new subdomain later and every passkey breaks.
My rule: register passkeys against your registrable parent domain unless you have a hard isolation reason not to. You almost never want to relitigate this after you have a million credentials in the wild.
The origin you verify against, by contrast, does include scheme and port: https://app.example.com. In production that’s a single HTTPS origin. In dev it’s http://localhost:5173 or wherever Vite landed. WebAuthn permits localhost over plain HTTP as a special case, which is the only reason local dev works at all — but the moment you deploy, HTTPS is mandatory, and you want HSTS on too so nobody downgrades.
Here’s the config I keep at the top of the auth module:
const rpName = 'Example App';
const rpID = process.env.RP_ID; // 'example.com' in prod, 'localhost' in dev
const origin = process.env.RP_ORIGIN; // 'https://app.example.com' / 'http://localhost:5173'Drive both from environment variables. The number of outages caused by a hardcoded localhost shipping to prod is not zero.
The registration ceremony with simplewebauthn
@simplewebauthn/server (v13.3.1 as of this writing) handles challenge generation and attestation verification so you’re not parsing CBOR by hand. Registration is two endpoints.
First, generate options and stash the challenge on the session:
import { generateRegistrationOptions } from '@simplewebauthn/server';
app.post('/passkey/register/options', async (req, res) => {
const user = req.user; // already authenticated via existing session
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: user.email,
userID: new TextEncoder().encode(user.id),
attestationType: 'none',
excludeCredentials: (await getUserCredentials(user.id)).map((c) => ({
id: c.id,
transports: c.transports,
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
req.session.currentChallenge = options.challenge;
res.json(options);
});A few choices worth defending. attestationType: 'none' — unless you’re a bank that needs to prove a credential lives on a specific certified authenticator, asking for attestation just adds friction and privacy concerns for no benefit. excludeCredentials stops a user from registering the same authenticator twice, which otherwise produces a baffling “you already have a passkey here” dead end. And residentKey: 'preferred' gets you discoverable credentials so the user can later click “Sign in with a passkey” without typing their username first.
The browser takes those options, calls startRegistration(), and posts the response back. You verify it:
import { verifyRegistrationResponse } from '@simplewebauthn/server';
app.post('/passkey/register/verify', async (req, res) => {
const user = req.user;
const expectedChallenge = req.session.currentChallenge;
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
if (!verification.verified) return res.status(400).json({ error: 'failed' });
const { credential } = verification.registrationInfo;
await saveCredential(user.id, {
id: credential.id,
publicKey: credential.publicKey, // Uint8Array — store as bytea/base64
counter: credential.counter,
transports: req.body.response.transports ?? [],
});
req.session.currentChallenge = undefined; // burn it
res.json({ verified: true });
});The thing to store is the credential ID and the public key. The private key never leaves the user’s device — that’s the entire point. Save the counter and transports too. Counter is a signature counter some authenticators increment to help detect cloned credentials; many platform authenticators just return 0 forever, so don’t build hard logic on it, but persist it anyway.
Authentication is the mirror image: generateAuthenticationOptions → browser startAuthentication() → verifyAuthenticationResponse, where you look up the credential by ID, pass it the stored public key, and on success update the counter. Same challenge discipline applies — generate, store on session, verify, burn.
The hybrid rollout that actually works
Do not flip passwords off on day one. I’ve watched a team try a hard cutover and roll it back within hours because the helpdesk caught fire.
The sequence that works:
Phase 1 — additive. Ship passkey registration as an opt-in inside account settings. Passwords and your existing OTP stay exactly as they are. You’re just collecting registrations and watching your dashboards. Nothing breaks because nothing changed for anyone who ignores it.
Phase 2 — passkey as the primary CTA. Now make passkey the recommended path. On login, if the user has a passkey, surface it first. On signup, pitch passkey before password. Microsoft’s own Entra rollout does exactly this with “passkey-preferred” authentication — detect the strongest registered method and prompt it first. Keep the password+OTP fallback link visible but secondary.
Phase 3 — deprecate passwords, carefully. Only after a real majority of sessions are passkey-authenticated. I’d want north of 80% before even discussing it, and that typically takes three to four months of Phase 2 nudging. “Deprecate” doesn’t mean delete — it means stop offering password as a primary option for users who have a working passkey, while keeping a recovery path alive.
The metric that matters is the share of successful logins using passkeys, not the count of registered passkeys. Plenty of users register one and then fall straight back to their saved password. If that gap is wide, your login UI isn’t surfacing the passkey aggressively enough — fix that before you touch passwords.
Account recovery without reopening the phishing hole
Here’s the uncomfortable truth: your authentication is exactly as phishing-resistant as your weakest recovery path. Spend a month making login unphishable, then offer “forgot your passkey? we’ll text you a code,” and you’ve just handed attackers the SIM-swap door you thought you’d locked. The recovery flow is where passkey programs quietly fail.
What good recovery looks like:
- Register two devices at onboarding. The single best recovery mechanism is a second passkey the user already has. Phone and laptop. Prompt for the second one right after the first succeeds, while motivation is high. This alone eliminates most “I lost my only device” tickets.
- Printed/saved recovery codes. Generate one-time codes at registration, show them once, store only hashes. Low-tech, offline, phishing-resistant because there’s nothing to phish in real time. This is the backbone fallback.
- Out-of-band magic links to a verified email, but treat email as a recovery channel, not an auth channel — and rate-limit it hard.
- Owner approval plus fraud checks on every fallback. Any path that doesn’t require an existing credential should trigger step-up scrutiny: device fingerprint, geolocation delta, a cooldown window, and ideally a human-reviewable signal for high-value accounts.
The principle: make the common case (lost one of two devices) trivial, and make the rare case (lost everything) deliberately slow and suspicious. A recovery path that’s as fast and frictionless as normal login is a recovery path an attacker will use instead of the front door.
Production gotchas that’ll cost you a day each
Windows Hello passkeys are device-bound and don’t sync. This trips up everyone. A passkey created with Windows Hello is bound to that machine’s TPM. Get a new PC and it does not come along — there’s no cloud sync the way Apple’s iCloud Keychain or Google Password Manager sync platform passkeys across a user’s devices. The mitigation is the same two-device advice from above: make sure a Windows-Hello user also has a synced passkey (phone) or recovery codes, or a wiped laptop means a recovery flow. Entra’s device-bound passkeys on Windows, generally available around late May 2026, make creation smoother but don’t change the bound-to-this-machine reality.
HTTPS and HSTS, no exceptions. Already said it, saying it again because it’s the first thing that breaks when someone tests on a staging box served over plain HTTP. WebAuthn refuses to run. Add HSTS so a downgrade attack can’t strip it.
Conditional UI vs. an explicit button. Conditional UI (autofill) shows passkeys in the username field’s autofill dropdown — slick when it works, but it depends on discoverable credentials and decent browser support, and it fails silently when conditions aren’t met. Ship an explicit “Sign in with a passkey” button as the reliable path and treat conditional UI as progressive enhancement. Don’t make autofill your only entry point.
Handle the second abort. If you kick off conditional UI and then the user clicks the explicit button, you’ve got two overlapping ceremonies and the browser will throw. Use an AbortController, abort the in-flight request before starting a new one, and swallow the resulting AbortError instead of surfacing it as a scary failure.
Passkeys vs. password managers: not a competition
People keep framing this as passkeys replacing password managers. They don’t. In 2026, 1Password, Bitwarden, and Dashlane all store passkeys and sync them across your devices — they’re part of the passkey ecosystem, not casualties of it. A user’s passkeys might live in iCloud Keychain, Google Password Manager, or their 1Password vault, and your app shouldn’t care which.
Where password managers still earn their keep is the long tail you can’t fix: legacy systems that will never support WebAuthn, shared team credentials, API keys, the router admin page, that one vendor portal stuck in 2009. Passkeys handle the modern, user-facing login. Managers mop up everything else. Most security-conscious users will run both for years.
If you’re still choosing where to store passkeys for your own team, a synced manager beats a device-bound platform authenticator on exactly the dimension that bites hardest — the new-laptop problem above.
Pick one low-stakes internal app, wire up registration and login with the snippets here, register two passkeys on it from a phone and a laptop, then deliberately “lose” one and walk your own recovery flow. The hour you spend feeling where it’s clunky will teach you more than any vendor demo about what your real users are going to hit.
Sources: Microsoft Security Blog — World Passkey Day 2026, FIDO Alliance / The Verge — Microsoft goes passwordless by default, @simplewebauthn/server docs, SimpleWebAuthn releases.