skip to content

When a passkey assertion arrives, which checks must your server run and what is each one for?

level: middleimportance: must knowfreq 75%

answer

  1. compare against what you issued
  2. type, challenge, origin, rpIdHash, flags
  3. resolve the record before the signature
  4. signature over authenticator data plus client-data hash

basics

~20 s

A passkey assertion is verified by comparison: resolve the credential, confirm the ceremony type is webauthn.get, match the challenge you issued, check the origin is expected, check rpIdHash is the SHA-256 of your rpId, check the flags, then verify the signature.

solid answer

~40 s

The response carries `clientDataJSON`, `authenticatorData` and a signature over `authenticatorData` concatenated with the SHA-256 hash of `clientDataJSON`. First resolve the credential record - either it is one of the ids you put in `allowCredentials`, or you resolve it from the returned `userHandle`. Then compare: `type` is `webauthn.get`; the `challenge` is the one you issued and have not spent; the `origin` is in your expected set; `rpIdHash` equals the SHA-256 hash of the `rpId` you expected; the user-present flag is set and the user-verified flag too if you required verification. Only then verify the signature under the stored public key. Each check answers a different question, and the `origin` comparison is the one that holds when the caller is not a browser you control.

code

pseudocode · 28 lines
pseudocode
verify_assertion(response):
    pending = pending_ceremonies.take(response.ceremony_ref)   # load AND delete
    if pending is null: reject("unknown or spent ceremony")

    record = credentials.find_by_id(response.credential_id)
    if record is null: reject("unknown credential")
    if pending.allow_credentials is not empty
       and response.credential_id not in pending.allow_credentials:
        reject("credential not offered for this ceremony")
    if pending.account is not null and record.account != pending.account:
        reject("credential belongs to another account")

    client_data = parse(response.client_data_bytes)
    if client_data.type   != "webauthn.get":          reject("wrong ceremony type")
    if client_data.challenge != pending.challenge:    reject("challenge mismatch")
    if client_data.origin not in EXPECTED_ORIGINS:    reject("unexpected origin")

    auth_data = parse(response.authenticator_data_bytes)
    if auth_data.rp_id_hash != sha256(EXPECTED_RP_ID): reject("rpId mismatch")
    if not auth_data.user_present:                     reject("no user presence")
    if REQUIRE_UV and not auth_data.user_verified:     reject("user verification required")

    signed = response.authenticator_data_bytes + sha256(response.client_data_bytes)
    if not verify(record.public_key, signed, response.signature):
        reject("bad signature")

    record.note_sign_count(auth_data.sign_count)
    return record.account

go deeper

for a junior

Remember that verification is mostly comparison, not cryptography: the ceremony type, the challenge, the origin and the relying-party identifier hash are all checked against values the server already has. The signature comes last.

for a middle

Explain the order and why it is that order - you cannot verify a signature before you know which stored public key to use. Say what is signed: the authenticator data plus the hash of the client data.

for a senior

Show the failure modes that pass tests: a prefix match on origin, hashing a re-serialised structure, a credential id resolved without checking its account, a challenge spent only on success. Be precise about which layer the phishing resistance comes from.

for a principal

Treat the expected-origin set as a configuration asset with an owner. It grows with every environment and partner surface, and a loose entry there silently converts a phishing-resistant credential into one that answers for somebody else's page.

## The shape of an assertion When the skipper opens the quota return and taps sign-in, your server issues request options: a fresh `challenge`, the `rpId`, an `allowCredentials` list or none at all, and a `userVerification` preference. What comes back is three things. `clientDataJSON` is the client's account of the ceremony. `authenticatorData` is the authenticator's account of it. The signature covers `authenticatorData` concatenated with the SHA-256 hash of `clientDataJSON`, so both accounts are bound together and neither can be edited in flight. Verification is not cryptography first. It is a set of comparisons against things your server already knows, and the signature check is the last of them - there is no point verifying a signature you have not yet decided which key to verify it under. ## The checks, in order 1. **Resolve the credential record.** If you sent an `allowCredentials` list, the returned credential id must be in it. If you sent an empty list, you resolve the account from the returned `userHandle` and then confirm the credential record you found carries that same handle. 2. **Ceremony type.** `type` in `clientDataJSON` must read `webauthn.get`. A registration response must not be accepted here. 3. **Challenge.** It must equal the one you issued for this pending ceremony, and you spend it as you load it. 4. **Origin.** The `origin` the client reports must be one your server expects. 5. **Relying-party identifier.** `rpIdHash` in the authenticator data must equal the SHA-256 hash of the `rpId` you expected. 6. **Flags.** The user-present flag must be set. If you asked for user verification, the user-verified flag must be set too. 7. **Signature.** Verify it under the public key stored in the credential record, over the exact bytes received. 8. **Sign counter.** Compare `signCount` with the stored value and record the outcome as a signal. ## Why the origin comparison is the phishing defence The property comes from a pair of layers, and only one of them is yours. The client enforces relying-party-identifier scoping: it will only hand a page a credential whose `rpId` is that page's own domain or a registrable-domain suffix of it. A relay page on an unrelated domain therefore cannot obtain an assertion for yours at all - the private key never signs for a domain it was not scoped to, and there is nothing for a relay to forward. Your own comparison is the layer that holds when the caller is not a browser you control, and the layer that stops a sibling origin under the same registrable domain - a staging host, a partner subdomain, a forum someone stood up - standing in for the reporting service. Stated as a pair it is exactly true. Stated as *the server check alone stops phishing*, it overclaims; stated as *the browser handles it*, it underclaims and leaves your expected-origin list unwritten. ## What each check proves, and what it does not | check | what it proves | what it does not prove | |---|---|---| | challenge | this response answers the ceremony your server started | anything about who is present | | origin | the client was on a page you are prepared to accept | that the client is a browser | | `rpIdHash` | the authenticator signed for your relying-party identifier | that the origin is one of yours | | user-verified flag | a local gesture was performed at the authenticator | which human performed it | | signature | the private key for this credential record was used | that it is the only copy of that key | That last column is what separates a mid-level answer from a senior one. A valid assertion is a statement about a key and a ceremony, not about a person. ## The mistakes that survive code review - **Verifying over the re-serialised structure.** Parse `clientDataJSON` to read it, but hash the bytes you received. Re-encoding changes them. - **Matching the origin loosely.** A prefix or substring comparison accepts an attacker-registered domain that merely starts or ends the same way. Compare against an explicit set. - **Accepting a credential id belonging to another account.** Resolving the record by id and forgetting to check whose it is turns a valid assertion into a sign-in as somebody else. - **Letting the challenge outlive the ceremony.** Spend it when you load the pending record, not after the last check succeeds, or a failed attempt leaves it live for a retry. - **Treating the user-verified flag as optional in code but required in the design.** If your policy says verification is required, the ceremony must fail when the flag is clear.

  • Your verifier checks the signature and the challenge but never looks at the origin. What is exposed?
    Any origin whose relying-party-identifier scoping still matches yours - a staging host or a partner subdomain under the same registrable domain - and any caller that is not a browser and simply constructs its own client data. The client will not hand an unrelated domain a credential for your `rpId`, but it has no idea which of your own origins you meant to accept. Only your server holds that list.
  • Why hash the received client-data bytes rather than re-serialising the parsed object?
    The signature covers the exact byte sequence the authenticator was given. Any re-encoding - different key order, different whitespace, different escaping - produces a different hash and a valid assertion fails. Parse a copy to read the members, and keep the original bytes for the hash.
  • What does the user-verified flag entitle you to conclude?
    That the authenticator performed a local gesture - a PIN, a fingerprint, a face - before signing. It says a person was present and satisfied that authenticator, not which person. Binding it to an account is your server's job, done through the credential record, and the flag is only worth requiring if your ceremony actually fails when it is clear.

saying these in an interview costs you the question

  • Verifies the signature and stops, never comparing the origin
  • Thinks the rpIdHash check makes the origin comparison redundant
  • Accepts a response whose ceremony type belongs to the other ceremony
  • Claims the user-verified flag identifies the account holder
  • Hashes a re-serialised copy instead of the bytes received
  • Resolves the credential by id without checking whose account it is