22 // journal

What passkeys actually replace: WebAuthn explained for app owners

engineering · 2026-11 · 7 min read · engineering · auth · security

journal/what-passkeys-replace
What passkeys actually replace: WebAuthn explained for app owners

fig.01 · two ceremonies, one keypair: the server holds a public key and nothing worth stealing

Your login form stores a secret on your server and asks the user to reproduce it from memory. Every famous password failure descends from that design: phishing works because the user types the secret into the wrong site, reuse works because the same secret opens many sites, and breaches work because the server holds a list of secrets worth stealing. Passkeys do not patch those failures. They remove the shared secret, and the failures go with it.

The constraint

An auth credential has to prove the same user came back, resist replay and phishing, and survive the user losing a phone. Passwords technically do the first and fail the rest because they deputize a human as the security mechanism. The fix has to move proof-of-identity into cryptography while keeping sign-in easier than typing a password, or users route around it.

The options

Password plus OTP. The shared secret stays on your server, phishable and breachable, and the second factor adds friction plus SIM-swap surface on SMS. A real improvement over a password alone, and still a shared-secret system at its core.

A password manager. Fixes reuse and weak passwords, but the credential is still a string that gets typed somewhere, and your server still stores the verifier table attackers want.

A hardware key as a second factor. Strong cryptography, wrong job: it protects a password, it does not replace one, and a bound-to-one-device credential has no recovery story when the key is in a lake.

A passkey. The passkeys.dev definition is exact: passkeys are "a replacement for passwords," "a secret stored on one's devices," where "Relying Party servers store public keys." In spec terms a passkey is a discoverable WebAuthn credential (WebAuthn Level 3's glossary lists the terms as aliases, with "resident key" deprecated), meaning the authenticator stores everything needed to sign in without the server first identifying the user.

The mechanism

WebAuthn defines two ceremonies and nothing else. Per the spec: "the first is Registration, where a public key credential is created on an authenticator, and scoped to a Relying Party with the present user's account... The second is Authentication, where the Relying Party is presented with an Authentication Assertion proving the presence and consent of the user who registered the public key credential."

REGISTER
navigator.credentials.create()
server sends challenge + rp.id + user handle

authenticator mints a keypair scoped to that rp.id

server stores credential id + public key only
SIGN IN
navigator.credentials.get()
server sends a fresh random challenge

authenticator signs challenge + origin data

server verifies with the stored public key
SCOPE
credential only exercises on its own rp.id:
https origin or registrable domain suffix

(localhost exempt for dev), signature covers

the origin, so a phished one fails verification

The phishing resistance is not policy, it is scope. The spec says "a public key credential can only be used for authentication with the same entity (as identified by RP ID) it was registered with," a scoping enforced jointly by user agents and authenticators. Apple's version of the same guarantee: passkeys "are intrinsically linked with the app or website they were created for, so people can never be tricked into using their passkey to sign in to a fraudulent app or website." The challenge the server sends, per webauthn.guide, exists "to prevent replay attacks."

Underneath the ceremonies is ordinary public-key crypto. This runs anywhere Node runs and is the entire trust model:

import { generateKeyPairSync, sign, verify, randomBytes } from "node:crypto";

// registration: the private key stays in the authenticator;
// the relying party stores only the public key + credential id
const { publicKey, privateKey } = generateKeyPairSync("ec", {
  namedCurve: "P-256", // es256, webauthn's baseline algorithm
});

// sign-in: server issues a fresh challenge, authenticator signs it
const challenge = randomBytes(32);
const signature = sign("sha256", challenge, privateKey);

// verification uses only what the server stores
console.log(verify("sha256", challenge, publicKey, signature));
// true

// a signature over different data fails: replay protection
console.log(verify("sha256", randomBytes(32), publicKey, signature));
// false

The spec backs the key custody claim in one sentence: the credential private key "is expected to never be exposed to any other party, not even to the owner of the authenticator." Your database holds public keys, which are worthless to steal.

Where it bites

Trust moved to the sync fabric. Synced passkeys mean losing a phone no longer loses the credential: Apple's passkeys "are synced with iCloud Keychain" and "end-to-end encrypted, so even Apple can't read them," and Google Password Manager does the equivalent on Android. The trade is that the cloud account is now the anchor: compromise the Apple or Google account and the recovery factor, and the passkeys follow. For high-assurance cohorts the answer is device-bound passkeys (security keys, Windows Hello), which per the same spec model never leave the device and need their own backup story.

Recovery is out of scope, so it is your problem. The spec defines two ceremonies and stops. What happens when a user loses every device and their sync account is a policy you write, and passkeys.dev's own bootstrapping guidance falls back to "legacy" login challenges and account recovery steps. That recovery path is now the weakest link in your auth: attackers who cannot phish a passkey will phish your reset flow instead. A passkey-first login with a lazy email reset is a strong door behind a screen window.

The credential is welded to the domain. RP ID scoping means a passkey registered under login.example.com rules cannot be exercised from a phishing lookalike, and it also cannot be exercised from your next domain. Rebrands, domain migrations, and regional splits mean re-registration for every user. Pick the registrable-domain RP ID deliberately on day one.

The support matrix has edges. Apple's floor is iOS 16 on iPhone 8 and later, iPadOS 16 on iPad 5th generation and later, macOS Ventura, and Safari 16 on older macOS per Apple's passkeys Q&A; Google documents Android 9 (API 28) and above, with third-party providers needing Android 14; passkeys.dev's support matrix lists Windows Hello as device-bound with OS-level synced passkeys on Windows still marked planned. Consumer apps can ship passkey-first. Apps serving old Android fleets or Windows-Hello-only enterprises need the fallback designed, not bolted on.

The numbers

  • Minimum platforms, per the vendors: iOS 16 on iPhone 8+, iPadOS 16 on iPad 5th gen+, macOS 13, tvOS 16; Android 9 (API level 28) via Google Password Manager; Windows Hello device-bound on Windows 10 and 11.
  • The API only exists in secure contexts: the origin scheme must be https, with localhost over http exempted for development, per the spec's origin rules.
  • WebAuthn Level 2 has been a W3C Recommendation since April 2021; Level 3 formally equates "passkey" with the older "discoverable credential" and "resident key" terms.

Our position

For consumer-facing apps we build passkey-first: the passkey replaces the password as the daily credential, which deletes the phishing and credential-breach columns at once. It does not replace recovery, so we keep exactly one non-passkey path, treat it as the security boundary it is, and instrument it like one. For enterprise and regulated work we scope device-bound credentials and attestation separately, because the sync fabric is a trust decision a compliance team should make explicitly.

The takeaway

REPLACES
the daily password: keypair + origin scope
kills phishing, reuse, server secret breaches
KEEPS
recovery: spec ends at the ceremonies; your
reset flow is the new attack surface, design it

account linking: rp.id is welded to the domain
CEREMONY
register = create() + store public key
sign-in = get() + verify signed challenge

challenge exists to kill replay
SYNC
icloud keychain / google pm: e2e encrypted
device loss ok; cloud account is the anchor

device-bound keys for high-assurance cohorts
SUPPORT
ios 16+ iphone 8, macos 13, android 9+ (gpm)
third-party providers need android 14+

windows hello: device-bound; os-level sync

still marked planned
CONTEXT
https required; localhost exempt for dev

Passkey or password-plus-fallback is a data-model decision we make on day one of a custom software build, because retrofitting auth means touching every session your users hold. If you want passkey sign-in scoped for your app and its recovery path designed properly, send us the current auth flow.

// next step

We work this way on client projects too.

Tell us what you are making. We reply with a plan, a timeline, and a price.

← the journal