IdentitypasskeysWebAuthnFIDO2public keyphishing resistanceorigin binding

Passkeys and WebAuthn

Public-key authentication in the browser: the device keeps a private key, the server stores only the public key, and the signature is bound to the origin — which removes the shared secret and makes relayed credentials worthless.

▶ Run the labFollow the failure

Frame the problem

Security starts with a concrete asset, attacker capability and trust crossing.

Asset
The authentication credential itself, which under this design never leaves the user's device and never exists on your server.
Attacker & capability
A phisher operating a convincing replica of your login page, and an attacker holding a copy of your credential table.
Trust boundary
The origin boundary, enforced by the browser and cryptographically embedded in the signature rather than checked by a human reading a URL bar.
AssetThreatAttack SurfaceTrust BoundaryVulnerabilityExploit PathImpactMitigationDefense in DepthResidual Risk

What changes when there is no shared secret

Password authentication requires both parties to hold something related to the same secret: the user knows it, the server stores a derivative. That symmetry is the source of most of the problems — the secret can be phished from the user, cracked from the server, reused across sites, and replayed once captured.

WebAuthn removes the symmetry. At registration the authenticator (a phone's secure element, a laptop's TPM, a hardware key) generates a key pair scoped to your site. It keeps the private key and sends the public key to your server, which stores it as an ordinary, non-secret value. At login your server sends a random challenge; the authenticator signs it and returns the signature; your server verifies it against the stored public key.

Three consequences follow immediately. Your credential table is no longer worth stealing, because public keys are not secrets. There is nothing for the user to type, so there is nothing to phish in the traditional sense. And every site gets a different key pair, so credential reuse across sites is not merely discouraged but impossible.

Registration and authentication
Register: device generates a key pairLogin: server sends a random challengePrivate key stays on the devicePublic key → server (not a secret)Device signs challenge + origin + rpIdServer verifies with the public keySession established
UserLLMAgentToolDataDecisionHumanGuardrail

Why it resists phishing

The property that matters is origin binding. When the browser asks the authenticator to sign, it includes the origin of the page making the request — and the browser supplies that value from its own knowledge of where the page came from. The user cannot influence it, and the site cannot claim to be a different one.

So consider the attack that defeats TOTP. A user is lured to app-example-login.evil, which proxies to the real site. With TOTP the user types a code, the proxy relays it, and the attacker is in. With WebAuthn the authenticator signs a challenge bound to app-example-login.evil. That signature is presented to app.example, which verifies it against a stored public key and against the expected origin — and rejects it, because the origin does not match. The credential the attacker captured is not merely hard to use; it is arithmetically invalid at the target.

This is a different kind of defense from user education. It does not require the user to notice anything. The failure mode of human vigilance — being tired, being hurried, being on a small screen — is removed from the loop entirely, which is why phishing-resistant authentication consistently outperforms every other control against account takeover.

1async function verifyAssertion(userId: string, assertion: Assertion) {
2 const expected = await challenges.take(userId) // single-use, short-lived, server-generated
3 if (!expected) throw new AuthError('no pending challenge')
4
5 const cred = await credentials.byId(userId, assertion.credentialId)
6 if (!cred) throw new AuthError('unknown credential')
7
8 const client = JSON.parse(base64url.decode(assertion.clientDataJSON))
9
10 // 1. The challenge came from us and has not been used. Blocks replay.
11 if (!timingSafeEqual(client.challenge, expected)) throw new AuthError('challenge mismatch')
12 // 2. The origin the BROWSER saw. This is the phishing-resistance check.
13 if (client.origin !== 'https://app.example') throw new AuthError('origin mismatch')
14 if (client.type !== 'webauthn.get') throw new AuthError('wrong ceremony type')
15 // 3. The relying-party id hash inside authenticatorData must match our domain.
16 if (!equalBytes(assertion.rpIdHash, sha256('app.example'))) throw new AuthError('rpId mismatch')
17 // 4. The user was actually present (and verified, if we require it).
18 if (!assertion.flags.userPresent) throw new AuthError('no user presence')
19 if (requireUserVerification && !assertion.flags.userVerified) throw new AuthError('no user verification')
20
21 // 5. The signature covers authenticatorData || sha256(clientDataJSON).
22 const signed = concat(assertion.authenticatorData, sha256(assertion.clientDataJSON))
23 if (!verifySignature(cred.publicKey, signed, assertion.signature)) throw new AuthError('bad signature')
24
25 // 6. Counter must not go backwards — a regression suggests a cloned authenticator.
26 if (assertion.signCount > 0 && assertion.signCount <= cred.signCount) {
27 await audit.log('webauthn.counter.regression', { userId, credentialId: cred.id })
28 throw new AuthError('counter regression')
29 }
30 await credentials.updateSignCount(cred.id, assertion.signCount)
31 return { userId }
32}

What is genuinely hard about it

Passkeys solve the credential problem and hand you an account recovery problem, which is now the dominant design question. If the credential lives on a device and the device is lost, what happens? Synced passkeys — backed up to a platform account — solve this for most consumers by making the credential recoverable through the platform vendor, which also means the security of the account now partly rests on the security of that platform account. Device-bound keys avoid that dependency and make loss unrecoverable without a second enrolled credential.

The practical answer is to require at least two credentials for accounts that matter — two devices, or a device plus a hardware key — and to treat recovery-code fallback as an explicit, audited weakening rather than a normal path. Whatever fallback exists becomes the true phishing resistance of the system, because an attacker will use it instead.

Two more realities. Enrolment is a trust event: whoever registers a passkey controls the account, so enrolment must be gated on existing authentication and always notified. And coexistence is unavoidable for years: you will run passwords, TOTP and passkeys simultaneously, and an attacker will choose the weakest enabled method. Offering passkeys while leaving SMS enabled as a fallback yields SMS-level security for any account an attacker targets, which is worth stating plainly when the migration is planned.

  • Require two enrolled credentials for high-value accounts; a single device is a single point of failure.
  • Any fallback method you leave enabled is the real security level for a targeted account.
  • Gate enrolment on current authentication, notify on every enrolment and removal, and audit both.
  • Synced passkeys move part of the trust to the platform account; device-bound keys move it to physical possession. Choose deliberately.
  • The signature counter is a weak clone signal, not a guarantee — many authenticators do not increment it.

Key points

  • The server stores a public key, so the credential table stops being a target worth stealing.
  • Signatures are bound to the origin the browser reports, which makes a relayed credential invalid at the real site.
  • Phishing resistance is structural, not educational — it does not depend on the user noticing anything.
  • Recovery becomes the dominant design problem: enrol two credentials, and treat fallbacks as an explicit weakening.
  • Whatever weaker method remains enabled defines the real security level for a targeted account.

Boundary control exercise

This lesson uses the shared boundary-control exercise.

Boundary control check
Untrusted input / identity
Trust boundary
Privileged asset
Prevention may fail silently.

Follow the attack

Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.

  1. 1
    Attacker → proxy phishing site: capture whatever the user provides.
  2. 2
    WebAuthn assertion → bound to the attacker's origin, so it is rejected at the real site. This branch dies.
  3. 3
    Attacker → switch branch: target the fallback (SMS, TOTP, recovery codes) or the account-recovery process.
  4. 4
    Attacker → enrol their own passkey: if enrolment is reachable from a stolen session, it grants durable access.
Blast radius
  • Successful passkey enrolment by an attacker is durable account control that survives password resets.
  • Weak fallbacks negate the benefit entirely for targeted accounts.
  • Loss of the only enrolled device without a recovery path is a permanent lockout — a support and availability problem rather than a security one.

Defend, detect, recover

One prevention is a single point of security failure. Layer it and make failure observable.

Prevent
  • • Verify challenge, origin, rpId hash, user presence and signature server-side; never skip the origin check.
  • • Use single-use, short-lived, server-generated challenges to block replay.
  • • Require a second enrolled credential before allowing the removal of stronger fallbacks.
  • • Gate enrolment on step-up authentication and notify out of band.
Detect
  • • Alert on new credential enrolment, credential removal and any counter regression.
  • • Alert on authentication that falls back to a weaker method for an account that has passkeys enrolled.
  • • Track the ratio of passkey to fallback authentications per account; a sudden shift is a signal.
Respond & recover
  • • Remove attacker-enrolled credentials and revoke all sessions.
  • • Require re-enrolment through a verified channel and regenerate recovery codes.
  • • Review whether the weaker fallback that was used should remain enabled for this account class.
Residual risk
  • • Synced passkeys inherit the security of the platform account they sync through.
  • • Malware on an authenticated device operates inside the session regardless of how strong the login was.
  • • Coexistence with weaker methods is a long-lived transitional risk that must be actively closed.

Misconceptions

Claim
“Passkeys mean there is no secret to steal.”
Reality
There is no secret *on your server*, and none in transit. There is a private key on the device, protected by the device's security model, and there is whatever fallback you left enabled.
Claim
“Adding passkeys makes the account phishing-resistant.”
Reality
Only when the weaker methods are removed for that account. An attacker targets the weakest enabled path, not the strongest offered one.