Tutorial 25 of 54 · for anyone adding passkeys to a site that already has a sign-in · Markdown

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:

config/app.toml
[auth]
login = "/sign-in"

[[auth.methods]]
name = "password"
provider = "file"

[[auth.methods]]
name = "passkey"
provider = "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:

$ fsr dev app
auth      methods password, passkey, login page /sign-in, routes /auth/login, /auth/enrol, /auth/callback and /auth/logout

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:

$ curl -c jar -b jar -H 'accept: application/json' 'http://localhost:3000/auth/login?return_to=/account'
{"next":"present","kind":"choose","data":{"methods":["password","passkey"]}}
$ curl -c jar -b jar -H 'accept: application/json' 'http://localhost:3000/auth/login?return_to=/account&method=password'
{"next":"present","kind":"form","data":{"fields":["user","password"]}}
$ curl -c jar -b jar -H 'accept: application/json' -H 'content-type: application/json' -d '{"user":"alice","password":"wonder"}' http://localhost:3000/auth/callback
{"next":"complete","to":"/account"}

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:

$ curl -c jar -b jar -H 'accept: application/json' 'http://localhost:3000/auth/login?return_to=/account&method=passkey'
{"next":"present","kind":"webauthn.get","data":{"publicKey":{"challenge":"CQIM9xPFJ5Bjhvu62ghalTNLKWjdXaWIjHYnlHq_UWg","rpId":"localhost","timeout":60000,"userVerification":"preferred","allowCredentials":[]}}}

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:

$ fsr use app react
app/src/ui/PasskeySignIn.tsx
import { useState } from "react";
import { AuthFailure, signIn } from "@snapfire/fsr/client";

export function PasskeySignIn({ returnTo }: { returnTo: string }) {
  const [error, setError] = useState("");

  async function run() {
    setError("");
    try {
      await signIn({ method: "passkey", returnTo });
    } catch (e) {
      setError(e instanceof AuthFailure ? e.message : String(e));
    }
  }

  return (
    <div>
      <button type="button" onClick={run}>Sign in with a passkey</button>
      {error ? <p>{error}</p> : null}
    </div>
  );
}

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:

app/routes/sign-in/page.loader.ts
import type { Ctx } from "@snapfire/fsr/host";

export async function load({ query }: Ctx<"/sign-in">) {
  return { returnTo: query.return_to ?? "/account", denied: query.error === "denied" };
}

Place the island under the form:

app/routes/sign-in/page.tsx
import { Island } from "@snapfire/fsr/client/react";
import type { SignInProps } from "@generated/client";
import { PasskeySignIn } from "@src/ui/PasskeySignIn";

export default function SignIn({ returnTo, denied }: SignInProps) {
  return (
    <section>
      <h1>Sign in</h1>
      {denied ? <p>That name and password did not match.</p> : null}
      <form method="post" action="/auth/callback">
        <label>
          Name <input name="user" autoComplete="username" />
        </label>
        <label>
          Password <input name="password" type="password" autoComplete="current-password" />
        </label>
        <button type="submit">Sign in</button>
      </form>
      <Island when="load">
        <PasskeySignIn returnTo={returnTo} />
      </Island>
    </section>
  );
}

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:

app/src/ui/AddPasskey.tsx
import { useState } from "react";
import { AuthFailure, enrol } from "@snapfire/fsr/client";

export function AddPasskey({ csrf }: { csrf: string }) {
  const [message, setMessage] = useState("");

  async function add() {
    try {
      await enrol(csrf, { method: "passkey", returnTo: "/account" });
    } catch (e) {
      setMessage(e instanceof AuthFailure ? e.message : String(e));
    }
  }

  return (
    <div>
      <button onClick={add}>Add a passkey</button>
      {message ? <p>{message}</p> : null}
    </div>
  );
}

Place it on the account page beside the sign-out form and pass it csrf_token:

app/routes/account/page.tsx
import { Island } from "@snapfire/fsr/client/react";
import type { AccountProps } from "@generated/client";
import { AddPasskey } from "@src/ui/AddPasskey";

export default function Account({ name, csrf_token }: AccountProps & { csrf_token?: string }) {
  return (
    <section>
      <h1>Signed in as {name}</h1>
      <Island when="load">
        <AddPasskey csrf={csrf_token ?? ""} />
      </Island>
      <form method="post" action="/auth/logout">
        <input type="hidden" name="_csrf" value={csrf_token ?? ""} />
        <button type="submit">Sign out</button>
      </form>
    </section>
  );
}

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:

config/passkeys.json
{
  "credentials": [
    {
      "id": "5k-8XVFd02jUr-BUQWKhRg",
      "subject": "alice",
      "claims": {
        "role": "admin"
      },
      "key": {
        "alg": "Es256",
        "point": "BKlkPHm8ie2wnjyoQ2jZT94B-XY1xu-7ppJ2x9ECydVOrXyHkJAcyYG2_9OWWVNf0vcxI-7VhjBc5tojRDWjIRM"
      },
      "sign_count": 0
    }
  ]
}

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:

config/app.toml
[auth]
login = "/sign-in"
second_factor = "passkey"

[[auth.methods]]
name = "password"
provider = "file"

[[auth.methods]]
name = "passkey"
provider = "passkey"

The boot report now reads the methods in order:

auth      methods password then passkey, login page /sign-in, routes /auth/login, /auth/enrol, /auth/callback and /auth/logout

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:

app/src/ui/SignInForm.tsx
import { useState } from "react";
import { AuthFailure, signIn } from "@snapfire/fsr/client";

export function SignInForm({ returnTo }: { returnTo: string }) {
  const [user, setUser] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");

  async function run() {
    setError("");
    try {
      await signIn({ method: "password", returnTo, present: () => ({ user, password }) });
    } catch (e) {
      setError(e instanceof AuthFailure ? e.message : String(e));
    }
  }

  return (
    <form onSubmit={(e) => { e.preventDefault(); run(); }}>
      <label>
        Name <input value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" />
      </label>
      <label>
        Password <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
      </label>
      <button type="submit">Sign in</button>
      {error ? <p>{error}</p> : null}
    </form>
  );
}
app/routes/sign-in/page.tsx
import { Island } from "@snapfire/fsr/client/react";
import type { SignInProps } from "@generated/client";
import { SignInForm } from "@src/ui/SignInForm";

export default function SignIn({ returnTo, denied }: SignInProps) {
  return (
    <section>
      <h1>Sign in</h1>
      {denied ? <p>That did not check out. Try again.</p> : null}
      <Island when="load">
        <SignInForm returnTo={returnTo} />
      </Island>
    </section>
  );
}

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:

config/auth.toml
[[users]]
name = "bob"
password = "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:

$ curl -c jar -b jar -H 'accept: application/json' -H 'content-type: application/json' -d '{"user":"bob","password":"builder"}' http://localhost:3000/auth/callback
{"next":"present","kind":"webauthn.create","data":{"publicKey":{"challenge":"lbZVB0JXNpMu7bVd9DPSJIIBq5g06dRMxtk30tuQ6Eo","rp":{"id":"localhost","name":"pk"},"user":{"id":"Ym9i","name":"bob","displayName":"bob"}, ...

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:

$ curl -H 'accept: application/json' 'http://localhost:3000/auth/login?return_to=/account&method=passkey'
{"error":"invalid","message":"invalid: no sign-in method named `passkey`; the methods are password"}

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:

config/app.toml
[auth]
provider = "passkey"
login = "/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.