# Passkeys

A passkey lets a person sign in with their fingerprint, their face or
their device's PIN, with no password and no email to wait for. The device
holds a key, and the person unlocks it. The fingerprint or face never
leaves the device, and your app stores only a public key, so there is no
secret on the server to leak. A passkey works only on the site it was
made for, so a fake site can't use it. Passkeys are the WebAuthn and FIDO2
standards.

Jadpo does all of it for you:

- **Registering and signing in.** The exchanges between the browser and
  your server, called ceremonies, are built in: the one-time challenge,
  its expiry, the check of the site's origin, and the signature counter.
  Jadpo checks each as the `webauthn-rs` library does, and its tests hold
  it to that library's answers. It doesn't ask the device to prove its
  make (attestation).
- **Stored with the user** as the standard `WebAuthnCredential` type, never
  a biometric. The [personal-data report](personal-data.md) lists only
  each passkey's identifier and when it was last used.
- **Recovery is a magic link.** A person who has lost every device with a
  passkey signs in by email and adds a new one, so passkeys need magic
  links in the same app.
- **More than one passkey per person.** Each has a name the person gives
  it and can be removed, so a phone and a laptop each have their own.

`passkey:` goes in `sign_in:` beside `magic_link:`:

```jadpo
// authentication.jadpo
authentication {
    browser_session: {
        cookie: "notes_session"
        origin: config.browser_origin
    }
    sign_in: {
        magic_link: {
            address: Member.email
            link: "/sign-in/confirm"
        }
        passkey: {}
    }
}
```

| Setting | Default | Allowed |
|---|---|---|
| `expires_after:` | `5m`, how long a ceremony's challenge lasts | `1m` to `10m` |
| `path:` | `"/auth/passkey"`, where the generated routes go | a path of fixed segments |

- `passkey:` needs `browser_session:` with its `origin:`. Every answer
  from a device is checked against that origin. Its host is the
  relying-party ID, the site name a passkey is bound to. Jadpo reads it
  from your configuration, never from the request, and never widens it to
  a parent domain or another port. A passkey made for
  `https://notes.example` works only there.
- `passkey:` needs `magic_link:` in the same `sign_in:`. It is how a
  person with no passkey left gets back in.
- Every passkey checks the person, not just the device
  (`userVerification: "required"`), so it is two factors in one: the
  device, and the fingerprint, face or PIN that unlocks it.
- Every passkey is discoverable (`residentKey: "required"`): the device
  can find it without being told whose it is. So signing in asks for no
  address first, and tells no one which addresses have an account.
- The device lists a passkey under the person's address (the field
  `magic_link:`'s `address:` names) and your app's host.
- A person has at most 20 passkeys.

## The routes

Jadpo generates seven routes under `path:`, `/auth/passkey` unless you
change it. Anyone may call the two sign-in routes. Adding and managing
passkeys needs the person signed in with a browser session. Any other
credential, an API token among them, is 403 `not_permitted`, since a
passkey it added would outlive it. Every write needs the session's CSRF
token and an `Origin` naming your app's site, as any write with the
session cookie does:

```text
POST   /auth/passkey/sign-in/options     options for signing in
POST   /auth/passkey/sign-in             the device's answer: starts a browser session
POST   /auth/passkey/register/options    options for adding a passkey (signed in)
POST   /auth/passkey/register            the new passkey and its name: keeps it (signed in)
GET    /auth/passkey                     the person's passkeys (signed in)
PATCH  /auth/passkey/{passkey_id}        renames one (signed in)
DELETE /auth/passkey/{passkey_id}        removes one (signed in)
```

## Signing in

Signing in is two requests around the browser's own prompt. The first,
with no body, gives the options for `navigator.credentials.get()` (which
`PublicKeyCredential.parseRequestOptionsFromJSON` reads) and a cookie
binding them to this browser:

```text
POST /auth/passkey/sign-in/options

200 OK
{ "publicKey": { "challenge": "Vh3q...", "rpId": "notes.example", "userVerification": "required", "timeout": 300000, ... }, "mediation": "conditional" }
Set-Cookie: notes_session_passkey=...; HttpOnly; Secure; SameSite=Strict
```

The frontend posts what the device answers, as WebAuthn's `toJSON()`
writes it:

```text
POST /auth/passkey/sign-in
{ "credential": { "id": "AQID...", "rawId": "AQID...", "type": "public-key", "response": { "clientDataJSON": "...", "authenticatorData": "...", "signature": "...", "userHandle": "..." } } }

204 No Content
Set-Cookie: notes_session=...; notes_session_csrf=...
```

- Each options request makes a new challenge of 32 random bytes, kept in
  the `jadpo_passkey_challenges` system table with what checking the
  answer needs: server-side, never in the cookie. The cookie,
  `<cookie>_passkey` (`HttpOnly`, `Secure`, `SameSite=Strict`, lasting
  `expires_after:`), holds 32 more random bytes kept only as a verifier,
  and binds the challenge to the browser that asked; a new request
  replaces that browser's earlier challenge.
- `mediation: "conditional"` lets the browser offer the passkey in the
  address field's autofill; a frontend with its own "Sign in with a
  passkey" button drops it.
- The sign-in `POST` needs an `Origin` naming the app's site, as a magic
  link's confirm does. Its challenge is used up when the answer arrives,
  whether or not the answer checks: a replayed answer, one whose challenge
  expired, and one from another browser are `passkey_challenge_expired`.
- The answer is checked: the challenge, the origin and the
  relying-party ID, that the person was present and verified, the
  signature against the stored public key, and the signature counter. Any
  failure is `passkey_rejected`, never saying which, and so is an answer
  from a passkey that was removed or never registered.
- **The counter.** A device that keeps a signature counter adds to it each
  time it signs. When the stored counter or the answer's is above zero,
  the answer's must be higher than the stored one (WebAuthn Level 3,
  §7.2, the signature counter's step). One that isn't means the key may
  have been copied, so the sign-in is refused (`passkey_rejected`) and the passkey left as it was,
  so the person signs in another way. A passkey that keeps no counter,
  which most synced ones don't, sends `0` every time and is never refused
  for it.
- A person whose `active:` doesn't hold is `user_disabled`, as on any
  request.
- On success the passkey's counter, backup state and last use are
  updated, a browser session starts as `sessions.start` starts one, and
  `<cookie>_passkey` is cleared. A passkey signs in a browser session
  only. Your own mobile and command-line clients sign in with a magic
  link's code.

## Adding a passkey

Adding a passkey is the same two steps, signed in:

```text
POST /auth/passkey/register/options

200 OK
{ "publicKey": { "challenge": "o7Wd...", "rp": { "id": "notes.example", "name": "notes.example" }, "user": { "id": "...", "name": "ada@example.com", "displayName": "ada@example.com" }, "excludeCredentials": [...], "authenticatorSelection": { "residentKey": "required", "requireResidentKey": true, "userVerification": "required" }, "attestation": "none", ... } }
```

```text
POST /auth/passkey/register
{ "name": "Work phone", "credential": { "id": "AQID...", "rawId": "AQID...", "type": "public-key", "response": { "clientDataJSON": "...", "attestationObject": "..." } } }

201 Created
{ "id": "0192d3c4-...", "name": "Work phone", "registered_at": "2026-10-18T09:00:00Z", "last_used_at": null }
```

- The challenge is bound to the session that asked for it, used up when
  the answer arrives whether or not it checks, and refused from any other
  session (`passkey_challenge_expired`); a new options request replaces
  that session's earlier challenge.
- `excludeCredentials` lists the person's passkeys, so a device that holds
  one already says so rather than making a second.
- `name` is required: the person's own name for the device, one line. A
  passkey registered already, to anyone, is `passkey_already_registered`;
  a 21st is `passkey_limit_reached`.
- The user handle the device keeps is 16 random bytes per passkey, never
  the user's `id`, so it says nothing about the account.

## Listing, renaming and removing passkeys

```text
GET /auth/passkey

200 OK
[{ "id": "0192d3c4-...", "name": "Work phone", "registered_at": "2026-10-18T09:00:00Z", "last_used_at": "2026-10-18T09:05:00Z" }]
```

```text
PATCH /auth/passkey/0192d3c4-...
{ "name": "Old phone" }

200 OK
{ "id": "0192d3c4-...", "name": "Old phone", "registered_at": "2026-10-18T09:00:00Z", "last_used_at": "2026-10-18T09:05:00Z" }
```

```text
DELETE /auth/passkey/0192d3c4-...

204 No Content
```

- Each reaches only the signed-in person's own passkeys: any other `id`
  is `passkey_not_found`. Removing the last one is allowed; the person
  signs in with a magic link and adds another. Removing a passkey ends no
  session it started.
- Each route's response shape is fixed
  ([Generated routes](generated-routes.md)). The routes belong to
  the first API that accepts browser sessions, and are listed in its
  record, its OpenAPI document and `jadpo routes`, marked generated.
- Your app writes no passkey routes of its own: one at any of these paths
  is `route.path_overlap`. Unlike magic links, there is no
  `generate_routes: false`. Every body is WebAuthn's rather than your
  app's, so your own route would have nothing to add.

## What is kept

`jadpo_passkeys`, a system table in the store of the principal's user
entity beside `jadpo_sessions`, holds each passkey as a
`WebAuthnCredential` from the [standard library](standard-library.md):

- its credential ID and its public key (a COSE key);
- its signature counter and its transports;
- whether it may be backed up, and whether it is;
- its name, and when it was registered and last used;
- the user's `id` and the user handle.

None of it is a secret: a copy of the table signs no one in. The
[personal-data report](personal-data.md) lists only each passkey's
credential ID and its last use.

## Recovery

A person with no passkey left signs in with a magic link or its code, then
adds a new passkey through the routes above. Nothing else recovers an
account, so a lost phone never needs an operator.

## A whole app with passkeys

The origin setting, the user, `passkey:` beside the browser session, and
magic links beside it, with the email connection and the app's name their
email needs:

```jadpo
config {
    browser_origin: Url {
        test: "https://notes.example"
    }
    mail_sender: Email
    smtp_host: Hostname
}
```

```jadpo
use journal/entities { Member }

application {
    display_name: "Notes"
    connections: {
        mail: {
            driver: Driver.smtp
            host: config.smtp_host
        }
    }
    email: {
        connection: mail
        from: config.mail_sender
    }
}

principal {
    user: {
        subject: Member.email
    }
}
```

```jadpo
authentication {
    browser_session: {
        cookie: "notes_session"
        origin: config.browser_origin
    }
    sign_in: {
        magic_link: {
            link: "/sign-in/confirm"
        }
        passkey: {
            expires_after: 2m
        }
    }
}
```

```jadpo
entity Member {
    id: Uuid
    email: Email {
        unique: true
    }

    policy: {
        create: [Access.public]
        read: [id]
    }
}
```

A route test drives the generated routes with `test_passkeys`
([In development and tests](in-development-and-tests.md)). Ada adds a passkey
and signs in with it:

```jadpo
use journal

test "a passkey signs Ada in" {
    var ada: Principal.user = journal.member_called("ada@example.com")
    var phone: TestPasskey = test_passkeys.register(ada, "Phone")
    var response: Response = test_passkeys.sign_in(phone)
    assert response.status == 204
}
```

The API record lists the routes Jadpo answers, as `jadpo routes` and
OpenAPI do. WebAuthn's own JSON goes in and out of the ceremonies, and the
record names each body for what it is:

```jadpo
// Generated by Jadpo from the routes of API main; update it with `jadpo api update`, never by hand.
record api 1

api main
    authentication browser_session

POST /auth/magic-link
    generated magic link
    authentication none
    input MagicLinkAsk (body)
    status 202

POST /auth/magic-link/confirm
    generated magic link
    authentication none
    input MagicLinkConfirmation (body)
    output MagicLinkConfirmed
    status 200

GET /auth/passkey
    generated passkey
    caller user
    output List<PasskeyView>
    status 200

POST /auth/passkey/register
    generated passkey
    caller user
    input PasskeyRegistration (body)
    output PasskeyView
    status 201

POST /auth/passkey/register/options
    generated passkey
    caller user
    output PasskeyCreationOptions
    status 200

POST /auth/passkey/sign-in
    generated passkey
    authentication none
    input PasskeyAnswer (body)
    status 204

POST /auth/passkey/sign-in/options
    generated passkey
    authentication none
    output PasskeyRequestOptions
    status 200

PATCH /auth/passkey/{passkey_id: Uuid}
    generated passkey
    caller user
    input PasskeyName (body)
    output PasskeyView
    status 200

DELETE /auth/passkey/{passkey_id: Uuid}
    generated passkey
    caller user
    status 204

GET /openapi.json
    generated openapi
    status 200

output MagicLinkConfirmed
    return_to: Text

output PasskeyView
    id: Uuid
    last_used_at: Instant?
    name: Text
    registered_at: Instant
```

## When the setup is incomplete

Without an origin there is nothing to bind a passkey to:

```jadpo
principal {
    user: {
        subject: Member.email
    }
}

authentication {
    browser_session: {
        cookie: "session"
    }
    sign_in: {
        passkey: {}
    }
}

entity Member {
    id: Uuid
    email: Email {
        unique: true
    }
}
```

```text
error: `passkey:` needs the browser session's `origin:`
 --> main.jadpo:12:9
   |
12 |         passkey: {}
   |         ^^^^^^^ no origin to bind to
  reason: A passkey works only on the site it was made for: every answer from a device is checked against the origin `browser_session:`'s `origin:` reads from configuration (area 10), never one the request names.
  next: Add `origin: config.<name>` to `browser_session:`, reading a `Url` setting (agent)
  rule: semantic.auth_setting_required
  help: https://jadpo.dev/docs/errors/semantic.auth_setting_required

check failed: 1 error, 1 file
```

Without magic links, a person who loses every device has no way back in:

```jadpo
config {
    browser_origin: Url
}

principal {
    user: {
        subject: Member.email
    }
}

authentication {
    browser_session: {
        cookie: "session"
        origin: config.browser_origin
    }
    sign_in: {
        passkey: {}
    }
}

entity Member {
    id: Uuid
    email: Email {
        unique: true
    }
}
```

```text
error: `passkey:` needs `magic_link:` beside it
 --> main.jadpo:17:9
   |
17 |         passkey: {}
   |         ^^^^^^^ no way back in
  reason: A person who loses every device holding a passkey signs in with a magic link or its code and adds a new one; with no `magic_link:` in the same `sign_in:`, nothing gets them back in.
  next: Add `magic_link: { link: "<path>" }` to `sign_in:`, beside `passkey:` (agent)
  rule: semantic.auth_setting_required
  help: https://jadpo.dev/docs/errors/semantic.auth_setting_required

check failed: 1 error, 1 file
```
