# Add PasskeyBase passkeys to an existing application

You are a coding agent adding **PasskeyBase** to the project you are working in.
Read this whole guide before editing code. It is the authoritative integration
contract. If a published SDK README or older docs page disagrees with it,
follow this guide.

Guide: https://www.passkeybase.com/llm/integrate.md · Reference apps: https://www.passkeybase.com/docs/quickstarts/

## What you are building

The application **keeps its own users, passwords and sessions**. It stays the
identity authority. PasskeyBase is a *secondary* authentication system: it holds
passkeys for the app's users and proves who presented one. It never creates or
owns users on its own.

- **Identity mapping.** Each app user can have one PasskeyBase identity,
  `(issuer, subject)`. The app stores that *binding* and treats it as
  authoritative. PasskeyBase also records the app's user ID as `external_subject`,
  so it is always the app's **immutable** user ID, never an email.
- **Many passkeys per identity.** An identity holds several credentials (up to
  20), one per phone, laptop or FIDO2 security key. Adding one never changes the
  identity.
- **Sign in with a passkey** resolves only through the binding to the existing
  app user. It then creates the app's normal session.
- **Signup with a passkey** (optional) creates an app user whose only credential
  is a passkey. The app reserves the user ID, PasskeyBase registers the passkey,
  and only then does the app create the user and binding.
- **Recovery** (lost passkeys, account changes) is the app's job: email reset,
  password, support. The user then adds a new passkey. PasskeyBase never resets
  anything.

Do not remove or weaken the existing login. Do not create a second account
system.

### Non-negotiable rules

- Never link or create accounts by **email** or any other claim. Only a stored
  `(issuer, subject)` binding identifies a local user.
- Never trust the browser (query strings, `postMessage`, popup results,
  `localStorage`) as proof of enrollment or login. Every success is confirmed
  server-to-server.
- The client secret, tokens and enrollment ticket/verifier are created and kept
  **server-side**. A pure SPA needs a small backend (the React reference app has one).
- Adding a passkey requires an existing credential proven **within the last 5
  minutes** (a password check, or a passkey sign-in). Re-prompt if needed.
- "No passkey on this device" never means "new user". Passkey signup goes through
  the app's normal uniqueness checks. If the email already has an account, say
  so: "sign in with your existing method, then add this device's passkey".
- If a PasskeyBase call fails, the existing sign-in must keep working. Never
  fake a success state.

## Step 0: survey the project, then ask for configuration

Before writing code, find and note:

- The user model and its **immutable** primary key. Use the ID, not the email or
  username, because those can change.
- How sessions are created, rotated and destroyed, and how CSRF is enforced.
- The login page, the account/security settings page and the logout handler.
- The persistence layer and migration tool.
- An existing OIDC/OAuth client library for this stack. Prefer a maintained one
  (e.g. `openid-client` for Node, `golang.org/x/oauth2` + `go-oidc` for Go,
  `Microsoft.AspNetCore.Authentication.OpenIdConnect` for ASP.NET, Authlib for
  Python, OmniAuth/`openid_connect` for Ruby). Don't hand-roll JWT validation.

Configuration comes from the PasskeyBase dashboard. Read it from environment
variables, never commit it, and add placeholders to `.env.example`:

| Variable | Meaning |
| --- | --- |
| `PKB_DOMAIN` | Tenant host, e.g. `acme.auth.ing`. The issuer is `https://PKB_DOMAIN/`. |
| `PKB_CLIENT_ID` | Confidential *Regular Web* client ID. |
| `PKB_CLIENT_SECRET` | Its secret (server-only). |
| `APP_ORIGIN` | This app's exact public origin, e.g. `https://app.example.com`. |

The PasskeyBase client must be **Regular Web**, use `client_secret_post` or
`client_secret_basic`, and have only the `authorization_code` grant. Register these
exact URLs on it (no wildcards):

- Login callback, e.g. `APP_ORIGIN/auth/passkeybase/callback`
- Enrollment callback, e.g. `APP_ORIGIN/auth/passkeybase/enrolled`
- Allowed web origin `APP_ORIGIN` and logout URL `APP_ORIGIN/`

Tell the user which callback URLs to register. If you cannot obtain real values,
leave placeholders and say so. Do not invent credentials.

## Step 1: storage

Add (via the project's migration tool):

```
passkey_bindings
  local_user_id   -- FK to users.id, UNIQUE (one identity per user; many passkeys)
  issuer          -- "https://PKB_DOMAIN/"
  subject         -- PasskeyBase subject, up to 512 chars
  created_at
  UNIQUE (issuer, subject)
```

If you implement passkey signup, users may have **no password**: make the
password column nullable, and make password login fail closed for such users.
Record when the session last proved a credential (`auth_at`) so "add another
passkey" can rely on a recent sign-in.

Pending enrollments live **server-side**, keyed to the current session. Use a
session field, a table, or a cache with a TTL ≥ 5 minutes:

```
{ local_user_id, session_id, transaction_id, completion_state,
  ticket, verifier, expires_at }
```

Pending login transactions also live server-side, keyed to a browser cookie:
`{ state, nonce, pkce_verifier, expires_at, popup }`.

## Step 2: Add a passkey (hosted enrollment)

This is the hosted-page path for an identity's **first** passkey. It works in every
browser and is the fallback for Step 2b. Where the browser supports related
origins, prefer Step 2b: it also handles additional passkeys and passkey signup.

### 2a. Backend: create the enrollment

Add an authenticated, CSRF-protected `POST` endpoint (e.g. `/auth/passkeybase/enroll`).
It must:

1. Require a signed-in local user whose last password (or other existing
   credential) verification was ≤ 5 minutes ago. Otherwise ask them to confirm
   their password first. Reject if the user already has a binding.
2. Generate two independent 32-byte random values, encoded as **base64url without
   padding**:
   - `verifier`. Keep it secret server-side.
   - `authorization_id`. Unique per request.
   Compute `browser_challenge = base64url(SHA-256(raw verifier bytes))`, hashing the
   32 raw bytes, not the base64 text.
3. Call PasskeyBase server-to-server:

```http
POST https://PKB_DOMAIN/passkeybase/v1/enrollments
Content-Type: application/json

{
  "client_id": "PKB_CLIENT_ID",
  "client_secret": "PKB_CLIENT_SECRET",
  "external_subject": "<immutable local user id>",
  "authorization_id": "<base64url 32 bytes>",
  "upstream_authenticated_at": <unix seconds of the recent password check>,
  "expires_at": <unix seconds, > now and ≤ upstream_authenticated_at + 300>,
  "intent": "initial_credential",
  "browser_challenge": "<base64url SHA-256 of verifier>",
  "callback": "APP_ORIGIN/auth/passkeybase/enrolled",
  "name": "<display name shown on the hosted page, ≤ 256 chars>"
}
```

- With `client_secret_basic`, omit both client fields from the body and send
  `Authorization: Basic base64(client_id:client_secret)` instead. Use exactly one
  method, the one the client is registered with.
- The JSON must contain **exactly** these fields; unknown fields are rejected.
- `callback` must be one of the client's registered callbacks, character for
  character, HTTPS, with no fragment and no `pkb_enrollment_state` parameter.

Success (`200`):

```json
{ "transaction_id": "...", "ticket": "...", "completion_state": "pkbs2_...",
  "expires_at": 1792315400, "landing_url": "https://PKB_DOMAIN/enroll" }
```

Validate it before use. `landing_url` must equal `https://PKB_DOMAIN/enroll`, and
`expires_at` must be in the future and no later than what you requested. Then store
the pending enrollment (Step 1) for this session, replacing any earlier one for
this user.

### 2b. Browser hand-off

Send the browser, in the same tab or a popup the user just opened, to an app
page (e.g. `GET /auth/passkeybase/enroll/launch`). It responds with an
auto-submitting form that **POSTs** to `landing_url`:

```html
<form id="pkb" method="post" action="https://PKB_DOMAIN/enroll">
  <input type="hidden" name="ticket" value="...">
  <input type="hidden" name="verifier" value="...">
  <noscript><button>Continue to passkey setup</button></noscript>
</form>
<script src="/static/pkb-launch.js"></script> <!-- document.getElementById('pkb').submit() -->
```

Requirements:

- Only serve it to the session that created the pending enrollment, and only
  for same-site navigations. HTML-escape the values.
- Send `Referrer-Policy: origin` on this response. PasskeyBase checks that the
  POST's `Origin` header equals your callback's origin. With `no-referrer`,
  browsers send `Origin: null` and the hand-off is rejected.
- If you send a CSP, allow `form-action https://PKB_DOMAIN` and a self-hosted script
  (no inline script unless you already use nonces).
- Never put the ticket or verifier in a URL.

The hosted page shows "Create a passkey for {name}", runs the WebAuthn ceremony,
then redirects to `callback?pkb_enrollment_state=<completion_state>`.

### 2c. Backend: enrollment callback

`GET /auth/passkeybase/enrolled?pkb_enrollment_state=...`:

1. Require exactly that one query parameter. Load the pending enrollment for the
   **current session**. Compare `pkb_enrollment_state` to the stored
   `completion_state` in constant time, and check it hasn't expired.
2. Confirm server-to-server (same client authentication as 2a):

```http
POST https://PKB_DOMAIN/passkeybase/v1/enrollments/status
{ "client_id": "...", "client_secret": "...", "transaction_id": "<stored transaction_id>" }
```

   Response: `{"status":"pending"}` or `{"status":"complete","subject":"..."}`.
3. On `complete`: in one transaction, refuse if that `(issuer, subject)` is already
   bound to a different user, or if this user already has a different binding.
   Otherwise insert the binding and delete the pending enrollment. On `pending`,
   tell the user it isn't finished and offer a "check again" action that repeats
   step 2. Never bind from the query string alone.
4. Redirect to the account page with a success message. In a popup, render a
   small page that notifies the opener (no data needed) and closes. The opener
   then re-reads state from your server.

## Step 2b: In-page registration (signup and additional passkeys)

Requires the related-origin setup described in Step 3b: the exact `APP_ORIGIN` is
an Allowed Web Origin of the client. The app page runs
`navigator.credentials.create()` with the tenant as RP. Both calls are
server-to-server, with the same client authentication as Step 2.

1. `POST /auth/passkeybase/register/options` (CSRF-protected). Decide *who* is
   registering, server-side:
   - **Signed in: add another passkey.** The user is the session's user. If the
     session's last credential proof is over ~4 minutes old, require the password
     (or a fresh passkey sign-in for passkey-only users). `external_subject` = the
     user's ID; `upstream_authenticated_at` = the time of that proof.
   - **Signed out: passkey signup.** Validate email and name like your normal
     signup. If the email already exists, return 409 "sign in, then add this
     device's passkey"; never link. Reserve a new immutable user ID **without
     creating the user**; `upstream_authenticated_at` = now.

   ```http
   POST https://PKB_DOMAIN/passkeybase/v1/registrations
   { "client_id": "...", "client_secret": "...", "origin": "APP_ORIGIN",
     "external_subject": "<user id or reserved id>",
     "upstream_authenticated_at": <unix s, within the last 5 min>,
     "name": "<label shown in the passkey picker, e.g. the email>" }
   ```

   Response: `{ "ceremony_id": "pkbr2_...", "expires_at": ..., "intent":
   "initial_credential" | "additional_credential", "publicKey": { "rp": { "id":
   "PKB_DOMAIN" }, "user": {...}, "challenge": "...", "excludeCredentials": [...],
   "authenticatorSelection": { "residentKey": "required", "userVerification": "required" } } }`.
   PasskeyBase decides the intent: an existing identity for this `external_subject`
   gets another credential, otherwise one is created. Check `publicKey.rp.id`. Store
   `{ mode, user id, email/name for signup, ceremony_id, expiry }` **server-side**,
   bound to the session (signup: the anonymous session), and return only `publicKey`.
2. `POST /auth/passkeybase/register/verify` (CSRF-protected) with the
   `navigator.credentials.create()` result. Remove the stored ceremony first
   (single use), check expiry and that the session is still the same (and still
   signed out for signup), then:

   ```http
   POST https://PKB_DOMAIN/passkeybase/v1/registrations/verify
   { "client_id": "...", "client_secret": "...", "ceremony_id": "<stored>",
     "credential": { "id", "rawId", "type", "response": { "attestationObject",
       "clientDataJSON", "transports" }, "clientExtensionResults", "authenticatorAttachment" } }
   ```

   Success: `{ "subject": "...", "external_subject": "...", "credential_id": "..." }`.
   **Require `external_subject` to equal the stored user ID.** Then, in one
   transaction:
   - **Signup:** re-check that the email is still free, create the user with **no
     password**, insert the binding `(https://PKB_DOMAIN/, subject)`, and sign the user in.
   - **Add:** insert the binding if missing. If it exists it must be the same
     `(issuer, subject)`, since another passkey keeps the identity. A binding owned by
     another user → refuse.

Browser: decode `challenge`, `user.id` and `excludeCredentials[].id` from base64url
before `create()`. Encode `rawId`, `attestationObject` and `clientDataJSON` after.
`InvalidStateError` means this authenticator already holds a passkey for the
account.

FIDO2 security keys with a PIN and resident-key support work like any other
passkey. Legacy U2F-only keys can't create discoverable, user-verified credentials,
so registration refuses them.

## Step 3: Sign in with a passkey (OIDC)

Use the stack's OIDC client with discovery at
`https://PKB_DOMAIN/.well-known/openid-configuration`:

- Authorization code flow, **PKCE S256**, random `state` and `nonce`,
  `scope=openid profile email`, `redirect_uri = APP_ORIGIN/auth/passkeybase/callback`,
  and token endpoint auth matching the client's method.
- `GET /auth/passkeybase/login` creates the transaction server-side, bound to a
  cookie, and redirects to the authorization URL. Add `?mode=popup` support if the
  UI offers a popup.
- The callback consumes the transaction **once** and checks `state`. It exchanges
  the code, then validates the ID token (signature via JWKS, `iss` =
  `https://PKB_DOMAIN/`, `aud` = client ID, `nonce`, expiry), as your library does.
- Look up `passkey_bindings` by `(iss, sub)`. **No match → refuse** with "This
  passkey isn't linked to an account. Sign in with your password and add a
  passkey first." Never create a user or match by email.
- On a match, create the application's **normal** session for that local user:
  rotate the session ID, and apply the same flags, roles and audit logging as a
  password login. Discard the PasskeyBase tokens unless the app needs them.
- Popup mode: the callback page closes itself after notifying the opener. The
  opener reloads its session from the server.

The hosted sign-in page opens the device's passkey prompt as soon as it loads.
No email field and no extra click is needed in most browsers.

## Step 3b: In-page passkey sign-in (no popup or redirect)

When the app's exact origin is registered as an **Allowed Web Origin** on its
PasskeyBase client, the tenant lists it in `https://PKB_DOMAIN/.well-known/webauthn`
(WebAuthn *Related Origin Requests*). Supporting browsers then let the app's own
page call `navigator.credentials.get()` with the tenant as RP ID, so the passkey
prompt appears directly on the login page. Implement this **in addition to**
Step 3 and keep Step 3 as the fallback.

Backend (both calls server-to-server, same client authentication as Step 2):

1. `POST /auth/passkeybase/assertion/options` (CSRF-protected, may be anonymous):

   ```http
   POST https://PKB_DOMAIN/passkeybase/v1/assertions
   { "client_id": "...", "client_secret": "...", "origin": "APP_ORIGIN" }
   ```

   Response: `{ "ceremony_id": "pkblc2_...", "expires_at": <unix s, ≤ 5 min>,
   "publicKey": { "challenge": "...", "rpId": "PKB_DOMAIN", "userVerification": "required", ... } }`.
   Check `publicKey.rpId` equals `PKB_DOMAIN`. Store `ceremony_id` and its expiry
   **server-side in the current browser session** (never trust one sent back by
   the browser), then return only `publicKey` and the expiry to the page.
   `403 origin_not_registered` means `origin` isn't an exact Allowed Web Origin
   of this client.
2. `POST /auth/passkeybase/assertion/verify` (CSRF-protected) with the browser's
   credential JSON. **Remove** the stored ceremony first (single use), check it
   hasn't expired, then:

   ```http
   POST https://PKB_DOMAIN/passkeybase/v1/assertions/verify
   { "client_id": "...", "client_secret": "...", "ceremony_id": "<stored>",
     "credential": { "id", "rawId", "type", "response": { "authenticatorData",
       "clientDataJSON", "signature", "userHandle" }, "clientExtensionResults" } }
   ```

   Success is `{ "subject": "...", "external_subject": "...", "authenticated_at": ... }`.
   PasskeyBase has already checked the signature, user verification, the exact
   origin, the challenge and that the passkey belongs to this client. Resolve
   `(https://PKB_DOMAIN/, subject)` through `passkey_bindings` exactly as in Step 3.
   **No binding → refuse.** `external_subject` is your own user ID as recorded at
   enrollment: require it to equal the bound user's ID. Then rotate and create the
   normal session. Any non-200 means "not signed in".

Browser:

- On load of a signed-out page, check
  `(await PublicKeyCredential.getClientCapabilities?.())?.relatedOrigins === true`.
  If true, fetch options **before** the user clicks. Some browsers (Safari) require
  the WebAuthn call to happen right after the click, without a network round trip.
  Refresh the options when fewer than ~30 s remain.
- Run **one preparation at a time**. The server keeps only the latest ceremony per
  session, so concurrent preparations can leave the page holding a superseded
  challenge. Discard prepared options whenever the session changes (sign-in or
  sign-out rotate it).
- On click: if options are ready, decode `challenge` (and any `allowCredentials[].id`)
  from base64url, call `navigator.credentials.get({ publicKey })`, base64url-encode
  `rawId`, `authenticatorData`, `clientDataJSON`, `signature` and `userHandle`, and POST
  them to the verify endpoint. Then reload the session from the server.
- Otherwise open the Step 3 popup synchronously in the click handler, so the
  popup isn't blocked.
- `SecurityError` from `get()` means the browser refused the related origin: switch
  to the popup for the next attempt. `NotAllowedError` means cancelled: prepare
  new options.

Limits: browsers read at most five distinct registrable domains from
`.well-known/webauthn`, and older browsers lack support, so the popup fallback is
required. Origins must match exactly, with no wildcards. Passkeys enrolled through
one client can only be verified by that client.

## Step 4: UI (one button)

- On the login page, put one **Continue with a passkey** button above the
  existing form.
  - With related-origin support and `capabilities.immediateGet === true`, call
    `get({ publicKey, mediation: "immediate" })`. It resolves with a local passkey
    or fails at once, with no UI, when this device has none to offer instantly.
  - On `NotAllowedError` (immediate found nothing, or the user cancelled; browsers
    don't tell them apart), show a **Create your account with a passkey** form
    (email and name, Step 2b signup) **and** a **Use a passkey from another device or
    security key** button that runs a normal modal `get()`. Immediate mode never
    covers security keys or phones.
  - Without related-origin support, the button opens the Step 3 popup.
- Keep the password form as an alternative.
- In account/security settings, offer **Add a passkey** / **Add another passkey**
  (Step 2b, or Step 2 as the fallback). Ask for the password only if the last proof
  is stale. Passkey-only users re-authenticate with a passkey instead.
  List the account's passkeys there too, with Rename and Remove (Step 5).
- Keep existing styling. Don't add PasskeyBase branding beyond what the
  user asks for.

## Step 5: Manage passkeys and accounts

Four server-to-server calls, with the same client authentication as Step 2. Each
names **your** user ID as `external_subject`. PasskeyBase acts only on the identity
bound to that ID for your client, and only on that client's passkeys. Unknown
users, identities and passkeys all answer 404 `identity_not_found` /
`passkey_not_found`.

```http
POST https://PKB_DOMAIN/passkeybase/v1/identities/lookup
{ "client_id": "...", "client_secret": "...", "external_subject": "<user id>" }
→ 200 { "subject": "...", "external_subject": "<user id>", "passkey_limit": 20,
        "passkeys": [ { "id": "<base64url credential id>", "name": "...",
          "type": "webauthn_platform", "created_at": <unix s>, "last_used_at": <unix s | null> } ] }

POST https://PKB_DOMAIN/passkeybase/v1/passkeys/rename
{ ..., "external_subject": "<user id>", "passkey_id": "<id>", "name": "<1–80 chars>" }
→ 200 { "id", "name", "type", "created_at", "last_used_at" }

POST https://PKB_DOMAIN/passkeybase/v1/passkeys/delete
{ ..., "external_subject": "<user id>", "passkey_id": "<id>" }
→ 204

POST https://PKB_DOMAIN/passkeybase/v1/identities/delete
{ ..., "external_subject": "<user id>" }
→ 204
```

- A passkey `id` is the browser's `credential.id`.
- Removing a passkey revokes it at once. Removing the last one keeps the identity,
  and the next Step 2b registration attaches to it.
- Deleting the identity removes it with all of its passkeys. A later Step 2b
  registration for the same user ID creates a new identity (a new `subject`), so
  update the binding then.
- Check that `lookup` returns your stored `subject` and your user ID. A 404 while
  you still hold a binding means the identity is gone: drop the binding.

App endpoints (CSRF-protected, signed in):

- **List** the passkeys on the account/security page: name, added, last used, and
  Rename / Remove.
- **Remove** needs the same recent proof as adding (Step 2b). Never remove the
  last passkey of a user who has no password; that would lock them out.
- **Turn off passkey sign-in** (password users only): `identities/delete`, then
  delete the binding.
- **Delete account:** call `identities/delete` first (204 or 404 both mean done),
  then delete the user, its binding and all of its sessions. If PasskeyBase fails,
  keep the account so the user can retry.
- Serialize these per user so two removals can't both pass the last-passkey check.
- Ask for a second click (or a confirmation) before removing or deleting.

## Errors

PasskeyBase errors are JSON `{statusCode, error, message, errorCode}`:

| Status / `errorCode` | Meaning | What the app should do |
| --- | --- | --- |
| 404 `not_available` | Existing-app enrollment isn't enabled for this tenant | Show "Passkeys aren't available yet"; keep password login; tell the developer |
| 400 `invalid_request` | Bad field, timing, callback or unknown field | Fix the request; log server-side, never the secret |
| 401 `invalid_client` | Wrong client ID/secret or auth method | Configuration error; check env vars and the client's auth method |
| 403 `enrollment_denied` | Transaction expired, replayed or not yours, or the user already has a passkey binding | Ask the user to start again |
| 403 `origin_not_registered` | In-page call for an origin that isn't an exact Allowed Web Origin | Register `APP_ORIGIN` on the client |
| 400 `too_many_credentials` | The identity already has 20 passkeys | Ask the user to remove one |
| 404 `identity_not_found` / `passkey_not_found` | No identity for this user ID, or no such passkey on it | Treat as already removed; drop a stale binding |
| 409 `passkey_changed` | Concurrent rename | Reload the list and retry |
| 409 `identity_shared` | Another client is bound to the same identity | Contact support; do not delete |
| 429 `rate_limit_exceeded` | Rate limited | Retry later |
| 503 `enrollment_unavailable` | Temporary | Retry later; password login unaffected |

Use timeouts (~15 s), don't follow redirects on server-to-server calls, and never
log secrets, tickets, verifiers or tokens. Rate-limit passkey endpoints separately
from password attempts. Pages request a challenge on load, and behind a proxy
every visitor can share one address, so a tight password budget would lock
everyone out.

## Tests to add

- Password signup/login still works with PasskeyBase unreachable.
- The enroll endpoint rejects anonymous, CSRF-less, stale-auth and
  already-bound users, and sends the exact request body (mock the HTTP call).
- The callback rejects a missing, extra or mismatched `pkb_enrollment_state`, a
  different session, `pending` status, and a subject bound to another user.
- The login callback rejects bad `state`, replayed transactions and an unbound
  `sub`, and never matches by email.
- The in-page verify endpoint rejects a missing, expired or reused ceremony and
  an unbound subject, and options are never issued cross-origin or without CSRF.
- Passkey signup creates no user before registration verifies. It refuses an
  existing email, and refuses a mismatched `external_subject`.
- Adding another passkey needs a recent proof, keeps the same binding, and refuses
  an identity bound to another user. Passkey-only users can't sign in with a password.
- Passkey login creates the same kind of session as password login.
- Passkey management requires sign-in and CSRF, sends only the session user's
  ID, refuses to remove a passkey-only user's last passkey, and needs a recent
  proof to remove one or delete the account.
- Account deletion deletes the PasskeyBase identity before the user, and keeps
  the user when that call fails.

For an end-to-end check, use a Chromium virtual authenticator (CDP
`WebAuthn.addVirtualAuthenticator`, resident key + user verification) against a
real tenant. Each popup is a separate target and needs its own authenticator.
Recent Chromium supports related origins, so it exercises the in-page path; to
test the popup fallback, override `getClientCapabilities` in an init script.

## Reference implementations

Working apps that follow this guide exactly (Node.js, React + Node backend, Go,
ASP.NET Core). Each has its own password accounts, enrollment and linked sign-in:
https://www.passkeybase.com/docs/quickstarts/. Their source is downloadable there. Mirror their
structure when in doubt.

## When you finish, report

- Files changed, the new migration, and new environment variables.
- The exact callback URLs and origin to register in the PasskeyBase dashboard.
- How to try it: sign in with a password → Add a passkey → sign out → Sign in
  with a passkey.
- Anything you could not complete or verify.
