# Add passkeys

A passkey is a credential that lives on the user's device and never leaves it. Once someone has enrolled one, they sign in **without typing anything** — no password, and on most devices no email address either, because the authenticator is what names them. When the device verified the person behind it — a fingerprint, a face, a device PIN — that single gesture is two factors, and the session it mints says so.

Passkeys are **off until you turn them on**, per product, and they work in both roles a WebAuthn credential can play: a first factor that replaces the password, and a second factor over any other first factor.

## What your users get

**A sign-in with nothing to type.** The browser offers the passkeys it holds in its own autofill dropdown over your email field, the user picks one, and the flow finishes. There is no identifier to enter and nothing to remember.

**Nothing to phish.** The credential is bound to your domain by the browser, so a look-alike site cannot ask for it and the user cannot be talked into handing it over. The private half never leaves the authenticator; what we store is the public half, which signs nothing.

**One gesture, two factors.** A device that verified the user before asserting gives them an `aal2` session immediately — see [the assurance table](#what-a-passkey-sign-in-is-worth).

**One passkey per device, named.** People enrol a passkey on their laptop and another on their phone, and each is listed under a name they chose, with how it is reached and when it was last used.

> **NOTE**
> Enrolment is the user's own choice. There is no way for you to enrol a passkey on somebody's behalf, deliberately: a passkey an operator could add is a passkey an operator could keep.

## Turn them on

The **Passkeys** section of **Users → Configuration** is the whole switch, and **Offer passkeys** is the only control most products touch. The same block is written through the product's API, where the field is `enabled`:

```json
{ "passkeys": { "enabled": true } }
```

Your **allowed origins** then have to decide one thing for the browser: the domain the credentials belong to. If every allowed origin is on one host, that host is used and there is nothing else to set — leave **Relying party ID** empty. If your product runs on two hosts — `app.example.com` and `admin.example.com` — you name their common parent explicitly, as the bare domain with no scheme or port:

```json
{ "passkeys": { "enabled": true, "rpId": "example.com", "rpName": "Acme" } }
```

[Configure authentication](/ship/users/configuration) has the block field by field, the exact rule the domain is resolved by, and the reason it is refused rather than guessed.

> **WARNING**
> Read that rule before you go to production: **the domain is fixed for the life of every credential enrolled under it**, and changing it later does not migrate anything — it leaves every passkey your users enrolled asserting a domain you no longer ask about.

## Sign in with one

`<SignIn/>` from `@lessly/users-react` 0.10.0 or later needs nothing from you. It renders a passkey button whenever the browser can run a ceremony, and while the sign-in form is open it also puts a passkey offer in the browser's autofill dropdown over the email field. Pass `passkeys={false}` to turn both off for a product that has not enabled them.

Writing your own form, the sign-in is one call:

```tsx

function PasskeyButton() {
  const passkey = usePasskeySignIn()
  if (!passkey.supported) return null

  return (
    <button type="button" disabled={passkey.busy} onClick={() => passkey.signIn()}>
      Sign in with a passkey
    </button>
  )
}
```

[Use the client libraries](/ship/users/client-libraries) has the autofill offer, the headless `@lessly/users-client` calls underneath, and the feature detection that decides whether to draw the button at all.

> **NOTE**
> **A dismissed prompt is not an error.** Closing the system sheet is an ordinary thing for a user to do: the call answers `null` and leaves the form exactly as it was. Render nothing for it.

## What a passkey sign-in is worth

| What the authenticator did | The session |
|---|---|
| Verified the user — fingerprint, face, PIN | `aal2`, and the second-factor challenge is skipped |
| Presence only, no verification | `aal1`, and the second-factor challenge runs as usual |

A verified assertion is possession of the device *and* something only that person can present, in one gesture. Asking such a sign-in for a second factor would be asking for a third, so Lessly Users does not: the flow completes, and the token carries `aal: 'aal2'` and `webauthn` among its `amr` methods. A product with `mfa.required` set has its floor met by that sign-in alone.

An unverified assertion is one factor and is treated as one. The account's other factors are offered as usual — minus passkeys themselves, because a factor cannot be its own second factor.

## Use a passkey as the second factor

A user who holds a passkey holds a verified factor, so it appears wherever factors appear: in their factor list, and as an answer to the second-factor challenge after a password, an email code, a magic link or an OAuth sign-in. `<SignIn/>` offers it on that screen when the parked attempt names it; on your own form the parked attempt lists `webauthn` among its `factors` and `submitSecondFactorPasskey()` is the way through.

[Add two-factor authentication](/ship/users/mfa) covers the second-factor screen as a whole.

## Let users manage them

`<PasskeySettings/>` is the self-service plane as one import — the list, **Add a passkey**, renaming a row, and removing one:

```tsx

export function SecuritySettings() {
  return (
    <SignedIn>
      <PasskeySettings />
    </SignedIn>
  )
}
```

**If you already render `<UserProfile/>`, you are done.** Its **Security** section embeds this same surface beside two-step verification, so a product with the account panel in it has passkey self-service already.

Adding, renaming and removing are sensitive: Lessly Users asks the user to prove it is still them before each one, and the component renders that prompt itself. The prompt takes a passkey as readily as a code — which is what keeps a user whose only credential *is* a passkey from being locked out of the screen that manages it.

> **WARNING**
> **Nobody may remove their last way in.** A delete that would leave the account with no way to sign in at all is refused as `passkey_last_way_in`, shown as a sentence, and the passkey stays. No amount of re-proving overrides it; the user adds another way in first.

## Handle a lost device

The user removes that passkey from their own settings, from any other device they are signed in on. It stops working immediately, and the credential still on the lost device asserts nothing.

If they cannot get in at all, someone on your side revokes that one passkey from the user's record — **Revoke** on the passkey's row of the **MFA factors** section, and the same call is available through the product's API and to an agent. It takes a written reason, is recorded verbatim in the audit trail, takes effect immediately rather than after a delay — a lost device is exactly the case where waiting is the wrong answer — and is refused when that passkey is the account's last way in. [Manage your end-users](/ship/users/user-management) has it alongside the factor reset.

Nobody at Lessly can lift a passkey off one of your users, and no operator anywhere can add one.

## Browser support

Passkeys need a browser with WebAuthn and a platform or security-key authenticator; the autofill offer needs conditional mediation on top of that, which is newer. Both are detected rather than assumed — `supported` gates the button and `autofillAvailable` gates the offer — so a browser that has neither simply sees your other sign-in methods. There is nothing to configure for it and no user agent to sniff.

## Next steps

- [Configure authentication](/ship/users/configuration): the `passkeys` block and the domain rule, field by field.
- [Use the client libraries](/ship/users/client-libraries): `usePasskeySignIn`, `usePasskeys`, `<PasskeySettings/>` and the headless client calls.
- [Run a sign-in flow](/ship/users/auth-flows): the passkey inside the sign-in flow, step by step.
- [Add two-factor authentication](/ship/users/mfa): the second factor, and the screen a passkey shares with it.
- [Sessions and tokens](/ship/users/sessions-and-tokens): the `aal` and `amr` claims a passkey sign-in mints.
