Every few years the industry declares the password dead, and every few years the password shrugs and keeps going. This time is different, and the reason isn't a better argument — it's better defaults. Apple, Google, and Microsoft ship passkey support in the operating system. Browsers autofill them the way they autofill passwords. Password managers sync them across ecosystems. The credential that was supposed to replace passwords finally behaves like the thing it's replacing, except it can't be phished, reused, or dumped in a breach. At Luminary we now treat passkeys as the primary credential for any greenfield product we build, and this article is the architecture we wish someone had handed us before the first one: how the protocol actually works, how to design enrollment and login without stranding users, what to do about sessions afterward, when to buy instead of build, and the mistakes that will quietly ruin your rollout.

Why passwords are finally losing
Passwords have three structural problems that no amount of complexity policy fixes.
First, they're a shared secret. The server has to hold something derived from the password, which means every database is a target and every breach leaks credentials that get replayed against every other site, because users reuse passwords no matter what we tell them.
Second, they're phishable by design. A password is just a string; it works equally well typed into the real site or a pixel-perfect clone. Even TOTP codes get proxied in real time by phishing kits — the attacker relays your code within its 30-second window. The credential itself has no idea where it's being used.
Third, they generate enormous support cost. Reset flows, lockouts, "forgot password" emails — for consumer products, credential trouble is consistently one of the biggest sources of support volume and funnel drop-off.
What changed isn't the cryptography — WebAuthn has been a W3C standard since 2019. What changed is syncing. Early WebAuthn credentials were device-bound: lose the laptop, lose the credential. That made them a great second factor and a terrible primary one. Synced passkeys — backed up through iCloud Keychain, Google Password Manager, or third-party managers like 1Password and Bitwarden — removed the "I dropped my phone in a lake" catastrophe, and that's what made passwordless-by-default viable for consumer products rather than just security-conscious enterprises.
How passkeys actually work
A passkey is a WebAuthn discoverable credential: a public/private key pair generated by an authenticator, scoped to a specific web origin. The private key never leaves the authenticator's trust boundary. The server stores only the public key.
The two ceremonies
Registration. The server sends a challenge plus parameters (relying party ID, user info, algorithm preferences). The authenticator generates a key pair, associates it with the RP ID and a user handle, and returns the public key with a signed attestation. The server verifies and stores the public key and credential ID.
Authentication. The server sends a fresh random challenge. The authenticator — after verifying the user locally via biometric, PIN, or device unlock — signs the challenge along with client data that includes the origin the browser actually observed. The server verifies the signature against the stored public key and checks that the origin and RP ID match what it expects.
That origin binding is the whole trick. The browser, not the user, decides which credential is eligible for which site. A passkey registered for yourapp.com simply does not exist as far as yourapp-login.evil.example is concerned. There is nothing for the user to be tricked into typing.
Two properties fall out of this that are easy to underappreciate:
- The server holds no secrets. A breach of your credentials table leaks public keys, which are useless to an attacker. Credential stuffing dies as a category.
- User verification is local. The biometric never leaves the device; the server only sees a bit asserting "the authenticator verified a human." You get two factors (possession + biometric/PIN) in one gesture.
Platform, roaming, and synced
Three authenticator shapes matter architecturally:
| Type | Example | Sync | Typical role |
|---|---|---|---|
| Platform | Touch ID, Windows Hello, Android screen lock | Often synced via ecosystem | Primary consumer credential |
| Roaming | YubiKey, other FIDO2 security keys | Device-bound | High-assurance / enterprise, recovery |
| Third-party manager | 1Password, Bitwarden, Proton Pass | Synced cross-ecosystem | Users who live across Apple/Google/Windows |
Cross-device flows fill the gaps: a QR code plus a Bluetooth proximity check (the CTAP hybrid transport) lets you sign in on a friend's laptop with the passkey on your phone. The proximity check matters — it's what stops a remote attacker from just relaying the QR code to a victim.
The synced-versus-device-bound distinction is a real policy question, not trivia. A synced passkey's security floor is the security of the user's cloud account and its own recovery flow. For a consumer SaaS product, that trade is clearly worth it. For a banking or admin surface, you may want to require device-bound credentials or step-up auth for sensitive operations — WebAuthn exposes flags (more on those below) that tell you which kind you're dealing with.
Designing a real signup and login flow
Here's the shape we ship, using @simplewebauthn because it handles the encoding drudgery and verification edge cases without hiding the protocol from you.
Registration
Server side, generate options and stash the challenge in the user's session:
// POST /api/webauthn/register/options
import { generateRegistrationOptions } from "@simplewebauthn/server";
const options = await generateRegistrationOptions({
rpName: "Acme",
rpID: "acme.com", // registrable domain, not the full origin
userName: user.email,
userDisplayName: user.name,
attestationType: "none", // you almost never need attestation
excludeCredentials: existingCredentials.map((c) => ({
id: c.credentialId,
transports: c.transports,
})),
authenticatorSelection: {
residentKey: "required", // discoverable credential = actual passkey
userVerification: "preferred",
},
});
session.currentChallenge = options.challenge;
return Response.json(options);
Client side, hand those options to the browser and post the result back:
import { startRegistration } from "@simplewebauthn/browser";
const options = await fetch("/api/webauthn/register/options", {
method: "POST",
}).then((r) => r.json());
const attestation = await startRegistration(options);
await fetch("/api/webauthn/register/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(attestation),
});
Then verify and persist — including the backup flags, which you'll want later:
// POST /api/webauthn/register/verify
import { verifyRegistrationResponse } from "@simplewebauthn/server";
const verification = await verifyRegistrationResponse({
response: body,
expectedChallenge: session.currentChallenge,
expectedOrigin: "https://acme.com",
expectedRPID: "acme.com",
});
if (!verification.verified || !verification.registrationInfo) {
return new Response("verification failed", { status: 400 });
}
const info = verification.registrationInfo;
await db.credentials.insert({
userId: user.id,
credentialId: info.credential.id,
publicKey: info.credential.publicKey,
counter: info.credential.counter,
transports: body.response.transports,
deviceType: info.credentialDeviceType, // "singleDevice" | "multiDevice"
backedUp: info.credentialBackedUp, // is it actually synced?
});
Note attestationType: "none". Attestation tells you which model of authenticator created the credential. Unless you have a regulatory requirement to allowlist hardware, requesting it buys you privacy prompts and verification complexity for nothing.
Login: lead with conditional UI
The single biggest UX win in modern WebAuthn is conditional mediation — passkeys appearing in the browser's autofill dropdown, exactly where users expect credentials to live. No "Sign in with a passkey" button that nobody understands; the user taps their email field, sees their passkey, touches the sensor, done.
Mark the field with autocomplete="username webauthn", then start a pending conditional request on page load:
// Raw API, to show what's actually happening
if (
window.PublicKeyCredential &&
(await PublicKeyCredential.isConditionalMediationAvailable())
) {
const { challenge } = await fetch("/api/webauthn/login/options", {
method: "POST",
}).then((r) => r.json());
const credential = await navigator.credentials.get({
mediation: "conditional", // resolves only if the user picks a passkey
publicKey: {
challenge: base64urlToBuffer(challenge),
rpId: "acme.com",
allowCredentials: [], // empty: any discoverable credential for this RP
userVerification: "preferred",
},
});
await finishLogin(credential); // POST to server for verification
}
Because the credential is discoverable, the server doesn't need to know who the user is before authentication — the authenticator returns the user handle, and you look the account up from the credential ID. Usernameless login falls out for free.
Fallbacks, in order
Passkeys-only is still too aggressive for most consumer products; some fraction of your users are on managed enterprise machines, exotic browsers, or shared devices. Our fallback ladder:
- Passkey via conditional UI (primary).
- Cross-device passkey — the QR/hybrid flow, surfaced when no local credential matches.
- Email magic link or OTP — the floor for consumer apps. Phishable, but breach-resistant, and it doubles as your recovery path.
- Password — only if you're migrating an existing base (next section). Don't add passwords to a new product just for comfort.
Crucially: after any fallback login, prompt passkey enrollment right there. The user has just proven ownership and is holding a capable device. That post-login moment converts far better than a settings page nobody visits.
Sessions: passkeys don't change the second half
WebAuthn authenticates a moment in time. Everything after the ceremony is the same session problem you've always had, and it's now the weaker half of your stack — a stolen session cookie bypasses the world's most phishing-resistant login.
Server-side sessions (opaque cookie, session store lookup) remain our default for first-party web apps: instant revocation, trivial "sign out everywhere," no claims going stale. With a modern cache or your primary database, the per-request lookup is rarely the bottleneck people fear.
JWTs earn their place when multiple services need to verify identity without a shared store, or you're issuing tokens to mobile/third-party clients. Keep access tokens short-lived — minutes, not days — because until expiry they're bearer instruments you cannot recall.
If you use JWTs, pair them with refresh token rotation: every refresh issues a new refresh token and invalidates the old one, and — the part people skip — a reused refresh token nukes the whole family:
async function refresh(presentedToken: string) {
const record = await db.refreshTokens.find(hash(presentedToken));
if (!record) throw new AuthError("unknown_token");
if (record.rotatedAt) {
// Someone is replaying an already-rotated token: this token was stolen.
await db.refreshTokens.revokeFamily(record.familyId);
throw new AuthError("token_reuse_detected");
}
await db.refreshTokens.markRotated(record.id);
return {
accessToken: signAccessToken(record.userId), // short TTL
refreshToken: await issueRefreshToken(record.userId, record.familyId),
};
}
Whatever you pick: cookies are HttpOnly; Secure; SameSite=Lax at minimum, and sensitive operations (payout details, credential management, deleting the account) get a step-up re-authentication with the passkey and userVerification: "required". That's the pattern that actually limits the blast radius of a hijacked session.
Build vs buy
The honest matrix, from having done all four:
| Option | Good fit | Watch out for |
|---|---|---|
| Clerk | Startups on React/Next.js that want polished passkey + org UI yesterday | Deep coupling to their components; MAU pricing as you scale |
| Auth0 | Enterprises needing SAML/OIDC federation, compliance checkboxes | Cost at volume; customization beyond the happy path gets painful |
| Supabase Auth | Products already on Supabase/Postgres | Auth roadmap follows the platform's priorities, not yours |
| Self-hosted (Keycloak, Ory, or hand-rolled on a WebAuthn library) | Data-residency requirements, auth is the product, or real scale where per-MAU pricing breaks | You own upgrades, uptime, and every security decision forever |
Our rule of thumb for client work: buy the commodity, build the differentiator. For most products, auth is commodity — a hosted provider gets you passkeys, bot detection, and breach-response muscle you won't match with two engineers. We reach for self-hosting when data residency demands it or when per-user pricing collides with a large free tier.
One thing we insist on regardless: own your user table. Keep users, credentials, and role assignments in your database keyed by your IDs, with the provider referenced by a foreign identifier. Migrating providers is painful; migrating providers when they own your only copy of the user graph is a rewrite.
Migrating an existing password base
Never flag-day this. The rollout that works is boring and incremental:
- Add passkeys alongside passwords. Ship registration and login support while changing nothing about existing flows.
- Enroll at moments of proven ownership. Right after a successful password login, after a password reset, after checkout. One clear prompt — "Sign in with your fingerprint next time" — not a modal ambush on every visit. Say fingerprint/face, not WebAuthn.
- Make the passkey win by default. Once a user has one, conditional UI should surface it before the password field gets typed in. The password becomes the thing you fall back to, not the thing you start with.
- Demote the password. For passkey-holding users, require step-up verification for password-based logins from new devices, or move the password behind a "more options" link.
- Offer deletion, don't force it. Let users with multiple registered passkeys remove their password entirely. Forcing it early just spikes support tickets.
Instrument every stage: enrollment prompt acceptance, passkey-vs-password login share, fallback usage, recovery volume. Ecosystem support is broad now, but your audience's device mix is what decides how hard you can push, and the dashboard will tell you.
Common implementation mistakes
These are the ones we've hit or been called in to fix.
RP ID scoping decided by accident
The RP ID permanently scopes every credential. Credentials registered to app.acme.com are invisible to acme.com and to admin.acme.com; credentials registered to acme.com work on every subdomain. Teams launch on app.acme.com, later move login to the apex or add a second product subdomain, and discover all existing passkeys are stranded — there is no rename. Register against the registrable domain (acme.com) from day one unless you have a specific reason to isolate subdomains. This also means passkeys can't follow you through a domain rebrand; if that's on the horizon, sequence it before launch.
Ignoring backup state
Authenticator data carries two flags: BE (backup-eligible) and BS (backed up). A credential with BE=0 is device-bound: the user loses that device, they lose that credential. If you let a user delete their password with only a device-bound passkey on file, you've built an account-loss machine. Store the flags at registration (as in the code above), and gate "remove other login methods" on having at least one backed-up credential — ideally two independent ones.
Account recovery as the unpatched hole
This is the big one. Your login is now phishing-resistant; your recovery flow is an email link. Attackers don't argue with cryptography — they walk around it, and every real-world "passkey bypass" story is really a recovery-flow story. Treat recovery as an attack surface with the same seriousness as login:
- Recovery via email is acceptable for consumer apps if it notifies all other channels, applies a review delay for high-value accounts, and triggers step-up requirements on sensitive actions afterward.
- Encourage a second passkey ("add your phone as a backup") and offer one-time recovery codes at enrollment.
- Support-desk recovery needs hard identity verification procedures. Social-engineering the help desk is the canonical bypass.
Smaller but real
- Treating the signature counter as a hard security gate. Synced passkeys legitimately report a counter of zero or non-incrementing values. Log anomalies for device-bound credentials; don't hard-fail everything on a counter mismatch or you'll lock out iCloud users.
- Requesting attestation you don't need, then failing verification on authenticators you've never heard of.
- Skipping
excludeCredentialsat registration, so users mash the enroll button and accumulate five identical passkeys they can't tell apart. - Not naming credentials. Store creation date and let users label devices, or your "manage passkeys" page is an unmanageable list of "Passkey (1)".
- Verifying the challenge but not the origin. The signed client data includes the origin the browser saw; checking it server-side is not optional. Good libraries do this for you — which is an argument for using one instead of hand-rolling CBOR parsing.
Phishing resistance — and what passkeys don't solve
It's worth being precise about the claim, because "unphishable" gets thrown around loosely. Passkeys are phishing-resistant because the credential is origin-bound and the browser enforces the binding: the signed response includes the observed origin, the RP ID check happens in the client platform, and there is no secret the user can be socially engineered into typing. Real-time proxy kits that shred TOTP have nothing to grab.
What they do not solve:
- Session theft. Malware or an XSS bug that exfiltrates your session cookie doesn't care how the session started. Post-auth hardening — short tokens, rotation, step-up on sensitive actions — is where your risk now lives.
- Compromised endpoints. An attacker with code execution on the user's device can ride authenticated sessions directly.
- Recovery-flow social engineering. Covered above; it's the moat's drawbridge.
- Cloud-account compromise. A synced passkey inherits the security of the ecosystem account syncing it. For most users this is still a massive upgrade over passwords — those accounts are heavily defended — but it's a dependency you should be honest about in your threat model.
- Consent phishing. OAuth "grant this app access" scams operate above the authentication layer entirely.
None of this is a reason to wait. It's a reason to spend the engineering time passkeys save you — no more password-strength bikeshedding, breach-reset fire drills, or credential-stuffing defenses — on the layers that still matter: sessions, recovery, and endpoint-aware step-up.
Takeaways
- Passkeys won because syncing made them survivable, not because the crypto got better. Treat them as the primary credential for new products.
- The security property that matters is origin binding: the browser, not the user, decides where a credential can be used.
- Lead with conditional UI (
autocomplete="username webauthn"); enroll users immediately after any successful fallback login. - Set your RP ID to the registrable domain on day one — it's permanent.
- Store and act on backup flags (BE/BS); never let a device-bound passkey become someone's only way in.
- Passkeys fix login, not sessions. Short-lived access tokens or server sessions, refresh rotation with reuse detection, and step-up re-auth for sensitive actions.
- Buy commodity auth, own your user table. Self-host only when residency, product differentiation, or pricing math forces it.
- Migrate incrementally: passkey alongside password, enroll at moments of proven ownership, demote the password, let users delete it.
- Your account recovery flow is now your weakest link. Design it like an attacker will start there — because they will.