Enrolling a passkey
POST /tenants/{tenant}/login-actions/required-action?action=configure-passkey
enrols a WebAuthn credential for the subject the authentication session is
bound to, the same way the configure-totp submission above enrols a TOTP
one. Nothing yet asks for the action on its own — there is no tenant switch
for passkeys, the way otp_required exists for TOTP — so it becomes pending
only when something adds it (today: a row in user_required_actions).
Signing in with the credential this enrols is
Signing in with a passkey.
No transcript here was executed, and this page does not show output for it.
Every other command in these pages was run against a running stack; this one
cannot be. A registration response can only be produced by
navigator.credentials.create() inside a browser, talking to a real
authenticator or a virtual one in the browser's own devtools — curl cannot
sign an attestation, and inventing a response body would make this section a
claim dressed as evidence. What is verified instead is an integration test,
which drives the enrolment against real PostgreSQL with a software authenticator
producing none-format attestations, and checks the stored credential, the
challenge being spent, and each refusal below.
What the flow is, stated rather than shown:
- A login that discovers
configure-passkeypending renders the enrolment page. The page carries the creation options the server generated — relying party, user handle, challenge, and the ids of any passkeys this subject already has, so the browser steers them to a different authenticator rather than replacing one. - The challenge is on the authentication session, not in the page's form. The response gives back a challenge of its own choosing; only the server's copy decides anything.
- The page posts the response JSON back in the
credentialfield, withauth_session_idand an optionallabel. - The server reads and clears the challenge in one statement, verifies the
response against it with
@simplewebauthn/server, and only then writes the credential:lookup_keyis the credential id, andsecret_dataholds the COSE public key, the authenticator's signature counter at registration, and its transports. Theconfigure-passkeyaction is cleared last, so a refused ceremony leaves it owed, and onecredentialaudit row,passkey.enrolled, is written in the same transaction; a refused ceremony writes none. The parked login then resumes: the recovery codes the enrolment owes come next, and acknowledging them answers the302to the client, with no second password. - The page's script is inline, because only a script can reach an
authenticator. The response's
Content-Security-Policynames a per-responsenonceand that script carries it — notunsafe-inline, so an injected script on this page still runs nowhere. Noconnect-src: this page is handed its options inline and fetches nothing, unlike the login page's passkey script.
Refused, each for its own reason:
- A response replayed after a successful enrolment — the challenge it answered no longer exists, so there is nothing for it to match.
- A response answering a challenge this server never issued.
- A response produced against another relying party or origin.
- A submission for an action the subject does not owe next: the
required-action route refuses any action that is not the head of their
pending set in the order
update-password,configure-totp,configure-passkey,generate-recovery-codes, whatever the form says. - A submission against a session whose authentication has not finished — a password passed and a second factor still outstanding, including one the subject enrolled in another attempt since — or one already spent on a sign-in. A required action blocks a login's completion, not its factors.
- Any enrolment at all on a deployment with no
ODUDU_PUBLIC_BASE_URL. The relying party id comes from that value and nowhere else, and the page reports the action as one that cannot be completed rather than binding a credential to a guessed domain. WithNODE_ENV=productionthe server refuses to boot in that state.