skip to content

WebAuthn & Passkeys

The server issues a challenge, verifies a signed assertion against a stored public key and watches a signature counter. Passkeys have made this the login flow interviewers reach for first.

on this pageshow

questions

6

Why must your server generate a passkey ceremony's challenge itself and accept each one only once?

level: juniorimportance: must knowfreq 62%

answer

  1. replay is the threat
  2. server issues it, server spends it
  3. entropy is a requirement
  4. 16 bytes is a recommendation
  5. delete on load, not on success

basics

~20 s

A passkey challenge is what makes each ceremony unrepeatable. The server generates it randomly, holds it against one pending ceremony and spends it on use, so a captured response cannot be replayed. The specification requires unguessable entropy and recommends at least 16 bytes.

solid answer

~40 s

The signature an authenticator returns covers the `challenge`, so the challenge is the only thing tying that signature to one moment in time. If the client could supply it, an attacker holding a captured response would simply replay the same value and get the same signature accepted. If the server reused one, every captured response would stay valid indefinitely. So the rule has three parts: generate it on the server from a cryptographic random generator, store it against exactly one pending ceremony, and delete it when that ceremony is loaded - not when it succeeds. Read the strengths correctly: unguessable entropy is a requirement, while at least 16 bytes is a recommendation.

code

pseudocode · 17 lines
pseudocode
begin_ceremony(account_or_null):
    challenge = secure_random_bytes(32)
    ref = secure_random_id()
    pending_ceremonies.put(ref, {
        challenge:      challenge,
        ceremony:       "get",
        account:        account_or_null,
        rp_id:          EXPECTED_RP_ID,
        require_uv:     true,
        issued_at:      now()
    }, ttl = 2.minutes)
    return options(challenge, ref, ...)

# on the way back, one atomic take - never read-then-delete
pending = pending_ceremonies.take(ref)
if pending is null or now() - pending.issued_at > 2.minutes:
    reject("ceremony expired or already used")

go deeper

for a junior

Know the threat by name: replay. The challenge is the only part of the signed data that changes between sign-ins, so it must be generated by the server, be unguessable, and work exactly once.

for a middle

Explain where the pending record lives between the two messages and what else belongs in it - the ceremony type, the account, the verification policy and an issued-at time - and why spending the challenge on load beats spending it on success.

for a senior

Bring up concurrency and expiry: an atomic take rather than read-then-delete, a shared store once more than one process answers, and a server-side age check because the client-side timeout is a hint you cannot enforce.

for a principal

Notice that the pending record is where ceremony policy is frozen. Deciding user verification at issue time and writing it down removes a whole class of downgrade argument; deciding it at verification time leaves the default exposed to whoever guesses it.

## What the challenge is for A passkey ceremony produces a signature over the authenticator's data and the hash of the client data, and the client data contains the `challenge` your server issued. Everything else in that signature is stable: the relying-party identifier does not change between sign-ins, the credential does not change, the flags rarely do. The challenge is the only field that makes today's signature different from yesterday's. That single fact is what makes a captured response worthless. Anyone who records the skipper's assertion off a quayside connection holds a signature over a challenge that has already been spent, and your server has nothing left to compare it to. ## The three rules, with their real strengths - **The server generates it.** If any part of the challenge comes from the request, an attacker chooses the value and replays a matching response. This is not negotiable. - **It comes from a cryptographic random generator.** The specification requires enough entropy that guessing is infeasible. An ordinary pseudo-random generator satisfies statistical tests and is still predictable from a handful of observed outputs. - **It is spent exactly once.** Store it against a single pending ceremony; take and delete that record as soon as the response arrives, before any comparison runs. Spending it only on success leaves it live for a retry after a deliberately failed attempt. **A note on strength.** The specification states the entropy requirement as a requirement and the 16-byte length as a recommendation. Candidates routinely promote the second into the first. Getting that distinction right is itself a signal: a specification's *should* is advice with a reason behind it, not a rule you can quote at a reviewer. ## Where the value lives between the two messages The challenge does not sit alone. The pending-ceremony record it belongs to normally carries: 1. The challenge bytes themselves. 2. Which ceremony this is - registration or authentication. 3. The account, when one is already known, and the credential ids you offered. 4. The relying-party identifier and the user-verification policy in force when the options were issued. 5. An issued-at timestamp, so a ceremony abandoned on the quayside expires rather than lingering. Holding the policy alongside the challenge is what stops a subtle downgrade: if you decide at verification time whether user verification was required, a caller who guesses your default gets to pick. If you decided it when you issued the options and wrote it down, there is nothing to guess. ## What each failure actually costs | failure | what an attacker gains | |---|---| | client supplies the challenge | a captured response replays forever, against a value the attacker chooses | | one challenge reused server-side | every captured response for that value stays valid | | non-cryptographic generator | the next challenge is predictable, so a response can be collected in advance | | spent only on success | one deliberately failed attempt leaves the challenge live for a retry | | never expires | an abandoned ceremony stays open indefinitely and the pending table grows without bound | ## The part people skip The pending record is state, and state has a home. On one process an in-memory map works and fails silently the moment a second process answers the callback. On several, the record must live somewhere both can reach it, and the delete must be atomic - a read-then-delete pair lets two concurrent posts of the same response both pass. Take-and-delete in one operation is the shape you want. The expiry is the other half. A skipper who starts a sign-in at the quay, loses signal and finishes an hour later should be asked again rather than silently accepted; a short timeout in the options is the client-side half of that, and your own issued-at check is the half that actually binds, because the client's timeout is a hint you cannot enforce. ## What the challenge is not Three things get attached to it that do not belong there, and each one weakens something else by being folded in: - **It is not an identifier for the user.** It says nothing about who is signing in, and deriving it from an account makes it predictable for that account. - **It is not a session.** Spending it grants nothing by itself; a verified ceremony is what your server turns into an authenticated session, and that is a separate decision with its own record. - **It is not a substitute for the origin comparison.** A single-use challenge stops the same response being presented twice. It does nothing about a response produced under an origin you never meant to accept, which is a different check entirely. Hold those apart and the challenge stays what it is: the one field that makes each ceremony unrepeatable, and nothing more.

  • Why store the challenge rather than recompute it when the response arrives?
    There is nothing to recompute from. The check is an equality test against a value this server issued and has not yet spent, so the value has to exist somewhere until the response arrives. Deriving it from something stable - the account id, a timestamp bucket - would make it predictable, which is exactly the property it must not have.
  • Is a 16-byte challenge a hard requirement?
    No. The specification requires enough entropy to make guessing infeasible and says a challenge should be at least 16 bytes. Sixteen random bytes comfortably meets the requirement, and larger is free, so 32 is a common choice - but a reviewer who calls a shorter one a specification violation has misread a recommendation as a rule.
  • Two processes answer the callback. What changes about the pending record?
    It has to live in storage both can reach, and the spend has to be a single atomic take rather than a read followed by a delete. Otherwise two concurrent posts of the same captured response can both find the record present and both pass - the replay you built the challenge to prevent.

A challenge is a cloakroom ticket the counter prints, not one you bring with you. The ticket proves nothing about the coat; it proves that the person at the counter is the person the counter served a moment ago. Print the tickets in a predictable order, or honour the same one twice, and anyone who glimpsed a ticket walks off with somebody's coat.

saying these in an interview costs you the question

  • Lets the client supply the challenge and echo it back
  • Reuses one challenge across every pending ceremony
  • Draws the challenge from an ordinary non-cryptographic generator
  • Calls the 16-byte floor a hard requirement of the specification
  • Leaves a spent challenge valid until its timeout expires
  • Keeps the pending record in one process's memory behind two processes
open as a page

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

level: middleimportance: must knowfreq 75%

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.

open as a page

At passkey registration, what does your server generate before the ceremony and store after it?

level: middleimportance: must knowfreq 70%

basics

~20 s

Passkey registration begins with server-generated options - a fresh random challenge, the relying-party identifier, an opaque user handle and an exclude list - and ends with one credential record per credential: credential id, public key, sign counter, user handle and transports.

open as a page

With an empty allowCredentials list, how does your server work out which account is signing in?

level: seniorimportance: should knowfreq 52%

basics

~20 s

An empty allowCredentials list means the server has not identified anyone yet. The authenticator offers a discoverable credential it holds for that rpId, and the assertion comes back carrying a userHandle the server matches to the account it issued that handle to.

open as a page

An assertion arrives whose signCount is lower than the value you stored - what do you actually do?

level: seniorimportance: should knowfreq 45%

basics

~20 s

A signCount regression is a signal that two copies of the private key may exist - never a verdict, and never an identification of which copy is which. Incrementing is only a recommendation on authenticators, so the specification leaves the response to the relying party.

open as a page

Should your relying party request attestation at passkey registration, and what does verifying it buy?

level: principalimportance: nice to knowfreq 34%

basics

~20 s

Attestation is evidence about the authenticator's model, not about the person. It is worth requesting only where a model policy exists and someone owns the trust anchors; otherwise none is the honest default, and the client may then zero the aaguid entirely.

open as a page