---
title: "Sign in with a passkey"
audience: "anyone adding passkeys to a site that already has a sign-in"
description: "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…"
snippet_caption: "config/app.toml"
snippet: "[auth]\nlogin = \"/sign-in\"\n\n[[auth.methods]]\nname = \"password\"\nprovider = \"file\"\n\n[[auth.methods]]\nname = \"passkey\"\nprovider = \"passkey\""
---

import { Install } from "@src/ui/Install";

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.

<Install captured="0.x" />

Start from the app you built in [350](https://snapfirers.com/fsr/learn/350-protect-a-route). 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:

```toml title="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:

```shell-session
$ 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:

```shell-session
$ 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:

```shell-session
$ 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:

```shell-session
$ fsr use app react
```

```tsx title="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:

```ts title="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:

```tsx title="app/routes/sign-in/page.tsx" {1,3,5,19-21}
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`:

```tsx title="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`:

```tsx title="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:

```json title="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`:

```toml title="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:

```shell-session
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:

```tsx title="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>
  );
}
```

```tsx title="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`:

```toml title="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`:

```shell-session
$ 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:

```shell-session
$ 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:

```toml title="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](https://snapfirers.com/fsr/learn/356-sign-in-with-google).
