Security model

The full verification checklist, design decisions, and what stays your job.

Why passkeys resist phishing

A password is a secret the user can be tricked into typing somewhere else. A passkey is a private key that never leaves the device, plus a browser rule: the authenticator will only sign for the Relying Party ID the credential was created for, and the browser will only accept an rpID matching the page's own domain.

A convincing replica at example.com.evil.net cannot obtain a signature for example.com. Not "the user should notice", but "the browser will not do it".

That property is why rpID is the one setting you must get right, and why part of the security boundary sits outside your code.

Verification checklist

Every check, in specification order. Each has a test that tampers with exactly that field and asserts the matching code.

Registration, WebAuthn §7.1

#CheckCode
1Challenge exists, unexpired, consumed on readchallenge_not_found
2Challenge was issued for a registrationtype_mismatch
3Signed challenge equals the issued one, constant timechallenge_mismatch
4clientData.type is webauthn.createtype_mismatch
5clientData.origin is allowedorigin_mismatch
6Not run in a cross-origin frameorigin_mismatch
7rpIdHash equals SHA-256 of the configured rpIDrpid_mismatch
8User-present flag setuser_not_present
9User-verified flag set when requireduser_not_verified
10Backup-state flag not set without backup-eligibleparse_error
11Attested credential data present and well formedparse_error
12rawId matches the credential ID inside the authenticator datamalformed_response
13Public key parses, algorithm was offeredunsupported_algorithm
14Attestation statement verifies, if sentattestation_failed
15Credential ID not already registered to anyonecredential_exists

Authentication, WebAuthn §7.2

#CheckCode
1Challenge exists, unexpired, consumed on readchallenge_not_found
2Challenge was issued for a logintype_mismatch
3Signed challenge equals the issued one, constant timechallenge_mismatch
4clientData.type is webauthn.gettype_mismatch
5clientData.origin is allowedorigin_mismatch
6Not run in a cross-origin frameorigin_mismatch
7Credential is registeredunknown_credential
8Credential belongs to the scoped accountunknown_credential
9User handle matches the credential's ownerunknown_credential
10rpIdHash equals SHA-256 of the configured rpIDrpid_mismatch
11User-present flag setuser_not_present
12User-verified flag set when requireduser_not_verified
13Signature verifies over authenticator data and client data hashbad_signature
14Signature counter did not go backwardscounter_regression

Design decisions

Failure throwsdecision

finishRegistration and finishAuthentication never return { verified: false }.

Why it works this way

Challenges are deleted on readdecision

Whether verification then succeeds or fails.

Why it works this way

Registration cannot take over a usernamedecision

Why it works this way

Login does not reveal whether an account existsdecision

Why it works this way

Constant-time comparisondecision

For challenges and RP ID hashes. Length differences leak; contents do not.

Deleting the last passkey is refuseddecision

Why it works this way

A strict CBOR parserdecision

It sits directly on attacker-controlled bytes, so it bounds nesting depth, validates every declared length against the remaining buffer before allocating, rejects duplicate map keys, rejects trailing bytes, and surfaces oversized integers as bigint rather than losing precision. See low-level primitives.

Zero runtime dependenciesdecision

Why it works this way

Attestation

Attestation answers "what kind of authenticator made this key?". It is optional, and for consumer passkeys usually absent by design: Apple, Google and 1Password return fmt: "none" with an all-zero AAGUID, because a per-model identifier is a tracking vector.

passkify's position:

Verifies any statement that arrivesnone, packed, fido-u2f, apple
Never requires oneabsence is not failure
trusted is honestfalse without roots

Why no FIDO Metadata Service is bundled

Unimplemented formats (tpm, android-key, android-safetynet) raise unsupported_feature, and only if you requested attestation in the first place.

The signature counter

Some authenticators increment a counter on every signature. A counter arriving lower than the stored one means the same credential has signed twice from different states, which is what a cloned authenticator looks like.

passkify rejects it by default. Two caveats:

Most passkeys report 0 foreverskipped when both are zero
It is detection, not defencesignal only

Override with the onCounterRegression hook.

What stays your job

Session managementyours

A verified finishAuthentication means "this person proved possession of a registered credential, just now". Use HttpOnly, Secure, SameSite=Lax cookies, rotate the session ID on login, and set a sane lifetime.

CSRFyours

The passkey endpoints are state-changing POSTs. Same-site cookies cover most of it. If your app uses CSRF tokens, pass one through configure({ headers }).

Rate limitingyours

Nothing here is a password guess, but /login/start issues a challenge to anyone who asks and each one costs a store write. Limit by IP, and cap challenges per account.

Transport securityyours

HTTPS everywhere, HSTS. WebAuthn will not run otherwise, but your API should not either.

Authorizationyours

passkify tells you who signed in. What they may do is yours.

Loggingyours

Log registrations, logins, credential deletions and counter regressions with the user ID.

Account recovery

The genuinely hard part of going passwordless, and no library can decide it for you. The failure mode is not subtle: a user who loses every device loses the account.

What helps, roughly in order of how much:

Push for a second passkey at signup

A synced passkey (deviceType: 'multiDevice') already survives losing one device, because it lives in the user's iCloud or Google account. A second one on a different platform survives losing that too.

This is the cheapest fix by a wide margin. Use listCredentials(userId).length === 1 to drive the prompt.

Keep a recovery channel you already trust

A verified email with a short-lived, single-use link is the usual answer.

Make recovery visible

Notify every registered channel when a passkey is added or recovery is used. A silent enrolment is indistinguishable from a takeover.

Watch the recovery path's strength

Recovery is the weakest link by construction. An emailed magic link means your passwordless account is only as strong as the user's email. That may well be the right trade; make it deliberately, and rate-limit it.

Reporting a vulnerability

Please do not open a public issue. Report privately to the maintainers and allow reasonable time for a fix before disclosure.