Signing in with a passkey, and no username
An enrolled passkey is a first factor on its own. The login page offers it beside the password fields, and pressing it asks for nothing typed:
<form method="post" action="/tenants/demo/login-actions/authenticate" id="passkey-form" data-failed="Your device did not finish signing in — {reason}. You can try again." data-cancelled="the request was cancelled" data-not-offered="this server is not offering passkeys">
<input type="hidden" name="auth_session_id" value="01a13fbd-81d7-…">
<input type="hidden" name="assertion" id="passkey-assertion">
<p><button type="submit" id="passkey-submit">Sign in with a passkey</button></p>
</form>
<p id="passkey-error" hidden></p>
<noscript><p>Signing in with a passkey needs JavaScript, because only the browser can talk to your authenticator. Use your username and password above.</p></noscript>
That button is on the /authorize response shown in
/authorize — it is part of the same page
as the username and password, so a tenant offers both and the person chooses.
It is rendered only where ODUDU_PUBLIC_BASE_URL is set; without it there
is no relying party id and nothing behind the button, so there is no button.
The <noscript> is not decoration. The assertion field is empty until a
script fills it, and a browser with JavaScript off can still press that
button — so an empty field is treated as no attempt at all rather than a
failed one, and the login falls through to the password exactly as if the
button had not been pressed. Reading it as an attempt would hand the
ALTERNATIVE group to a factor nobody could satisfy.
No transcript here was executed, and this page does not show output for it.
The same limit applies as to Enrolling a passkey,
for the same reason: only navigator.credentials.get() inside a browser,
talking to an authenticator, can produce an assertion — curl cannot sign one,
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
whole journey against real PostgreSQL with a software authenticator producing
real ES256 assertions, and checks each step and each refusal below. The steps
are that test's steps, and the server-side behaviour is what it asserts.
What the flow is, stated rather than shown:
- The page posts
auth_session_idtoPOST /tenants/{tenant}/login-actions/passkey-challengeand gets request options back as JSON. They carry a challenge and noallowCredentials, which is what tells the browser to offer every discoverable credential it holds rather than a list the server would have needed a username to build. - The challenge is on the authentication session, not in the page. It is issued per press rather than with the page, so a form left open overnight still gets a live one, and a rejected attempt can try again with no re-render.
- The page posts the assertion JSON back to
/tenants/{tenant}/login-actions/authenticatein anassertionfield, with the sameauth_session_id— the same endpoint the password uses. - Who is signing in comes from the assertion, before anything is
verified. The credential id it carries is what enrolment stored as
lookup_key, and reading that back is tenant-scoped by RLS, so a credential from another tenant resolves to nothing rather than to somebody else's subject. Resolution has to come first because verification needs the stored public key and counter as inputs. - The server reads and clears the challenge in one statement, then verifies the assertion against it — origin, relying party id, signature, and the authenticator's user-verified flag, which is required rather than preferred.
- The signature counter must have advanced past the stored one, or the credential is answering from two places at once. The write is a compare-and-swap, so two assertions replaying one counter value cannot both pass, and it happens only after the login is confirmed to be for the subject this attempt is already bound to.
- The session records
passkeyalone, and the ID token saysamr: ["hwk", "user"],acr: "2". A tenant withotp_requiredon does not ask for a code after a passkey, and does not make the subject enrol one either — enrolment demands a discoverable credential with user verification, so an assertion is possession of a key plus a check of who held it. That is two factors, andotp_requiredis a floor rather than a tax.
Refused, each for its own reason:
- An assertion whose counter did not increase. One that reports zero from a credential whose stored counter is also zero is accepted: WebAuthn §6.1.1 permits an authenticator that never counts, and refusing it would refuse a conformant device rather than catch a clone.
- An assertion replayed after a successful login — the challenge it answered was cleared by the statement that read it, so there is nothing left to verify against.
- An assertion for a credential enrolled in another tenant.
- An assertion the authenticator did not verify anybody for.
- An assertion whose credential id names nothing in this tenant, which
answers exactly as a wrong password does:
invalid_credentials.