Sign in with a passkey
You'll let a signed-in visitor add a passkey, sign in with it on its own and then require it after the password. The browser's prompt runs from one client call and the host checks every answer.
Before you start
Install from crates.io. You need no Node and no package manager.
cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version
Every command and screenshot on this page was captured with fsr 0.x.
Start from the app you built in 350. It has the file provider, config/auth.toml with alice, the middleware that guards /account, the /sign-in page and the /account page with its sign-out form.
Passkeys in brief#
A passkey is a key pair the visitor's device holds for one site. The site keeps only the public half. The browser's prompt is the ceremony: navigator.credentials.create makes a passkey and navigator.credentials.get signs with one. Each ceremony signs a fresh challenge, so an answer cannot be replayed.
A passkey is bound to a domain, the relying party. It is never an IP address. In development without document.origin the relying party is localhost, so browse to http://localhost:3000 and not 127.0.0.1.
Offer two methods#
A visitor needs a way in before they can add a passkey, so keep the password and add passkeys beside it. Replace the [auth] section:
[]
= "/sign-in"
[[]]
= "password"
= "file"
[[]]
= "passkey"
= "passkey"Each [[auth.methods]] entry is one way to sign in, in the order the page lists them. The passkeys are kept in config/passkeys.json. Run fsr dev app and the boot report names both methods:
Read the steps as JSON#
A sign-in is a run of steps. The host answers each one with where the browser goes, what the page acts on or where the flow landed. A request whose Accept names application/json gets each step as data:
With two methods and no method, the first step is choose. ?method=password presents the password form. Posting its fields to /auth/callback completes the flow. The passkey method answers with a webauthn.get step carrying the challenge:
The state between steps stays in custody, the part of the session no body can read. It never travels in the URL.
Add a passkey button to the sign-in page#
The password form from 350 stays as it is. It posts user and password to /auth/callback, which answers the password method's step. A passkey step needs the browser's prompt, so the passkey button is an island that runs the flow with signIn from the client. Add React if the app has none yet:
;
;
signIn begins at /auth/login with method and return_to, then loops. A webauthn.get or webauthn.create step runs the browser's prompt and posts the credential to /auth/callback. When the flow completes, the browser moves to returnTo. A refusal throws an AuthFailure.
The middleware from 350 still sends an anonymous visit to /auth/login, which lands on /sign-in?return_to=%2Faccount. The loader passes that path on:
;
Place the island under the form:
;
;
;
Open http://localhost:3000/account, sign in as alice with wonder and you are on /account.
Add a passkey from the account page#
Adding a method to whoever is signed in is an enrolment. enrol from the client posts to POST /auth/enrol and runs the steps the way signIn does. It changes the account, so it carries the page's CSRF token, which the client sends as x-sf-csrf:
;
;
Place it on the account page beside the sign-out form and pass it csrf_token:
;
;
;
Click Add a passkey. The host answers with a webauthn.create step, the browser asks you to make a passkey for localhost and the client posts it back. The host checks the challenge, the origin and the relying party, then saves the public key:
The passkey keeps the subject and the claims of the identity that made it. Signing in with it later gives the same identity back, so identity.subject and identity.claims.role read the same either way.
Sign in with the passkey alone#
Sign out, open /account again and click Sign in with a passkey. The step offers every passkey the device holds for the site, with allowCredentials empty. The one you pick names whose it is. You land on /account signed in as alice, with no password typed.
The host also checks the authenticator's counter. After this sign-in, sign_count in config/passkeys.json reads 1. A counter that does not move forward is refused, since that is what a cloned authenticator does.
Require a passkey after the password#
To make the passkey a second factor, name it in second_factor:
[]
= "/sign-in"
= "passkey"
[[]]
= "password"
= "file"
[[]]
= "passkey"
= "passkey"The boot report now reads the methods in order:
After the password, the host answers with a webauthn.get step limited to alice's own passkeys. That step is shown in place, on the sign-in page, so the password now goes through signIn too. Replace the island with one form that runs both steps:
;
;
;
;
;
The form step goes to present, whose answer is the two fields. The webauthn.get step that follows runs the browser's prompt from the same signIn call. A wrong password shows denied: unknown user or wrong password.
Sign in as alice with the password and the browser asks for her passkey. You are signed in only when the passkey completes for the same subject the password named.
Add a second account to config/auth.toml:
[[]]
= "bob"
= "builder"Bob holds no passkey, so after his password the step is webauthn.create instead. The output is cut short here. rp.name is the app's document.title:
The browser makes his first passkey and he lands on /account. The next time, he is asked for it.
The passkey is no longer a method of its own, so the Sign in with a passkey button is refused:
The sign-in page above no longer has that button. An enrolment still works: enrol from the account page adds a passkey to the account, the same as before.
Passkeys as the only method#
Once every account holds a passkey, the password can go:
[]
= "passkey"
= "/sign-in"The sign-in page then needs only a passkey button. With one method there is nothing to choose, so the button calls signIn({ returnTo }) with no method.
What custody holds#
Each method's tokens go into custody when its step completes. The file provider puts an access_token there and the passkey adds none. A body never reads custody. It reads identity, which only the auth routes set. Signing out clears identity and custody together.
Next up: 356. Sign in with Google.