# Adding passkeys via the API

Until now, every passkey in this service was created by a human, in their own browser, with the
extension. That is still the default, and still the path with the fewest moving parts. The
`enrol` scope adds a second one: an agent or a script that already drives a browser can add a
passkey to a site — **after the person has signed in to that site in the browser it drives**.

Read that clause twice. The service does not hold a password, a cookie or a session, so it
cannot get the browser past the site's login. What the `enrol` scope does is answer the
site's *registration* ceremony once a signed-in page asks for one. The first login is still a
human act; everything after it can be the agent's.

## What `enrol` unlocks

| MCP tool | REST route | Does |
|---|---|---|
| `passkey_begin_enrol` | `POST /v1/enrol/begin` | Mint an enrolment ticket and the page script. |
| `passkey_enrol_status` | `GET /v1/enrol/:ticket` | Where the ticket is. |
| `passkey_complete_enrol` | `POST /v1/enrol/complete` | The site's verdict: confirm, or withdraw. |
| `passkey_enrol_direct` | `POST /v1/credentials` | The whole attestation, for a caller running the ceremony itself. |
| — | `POST /v1/subjects`, `GET /v1/subjects`, `GET /v1/subjects/:id` | The end customers a multi-tenant integrator enrols for. |

The scope is labelled **"Add passkeys"** on the consent screen and the API-key form, and is off
by default. Revoking — `passkey_revoke`, `POST /v1/credentials/:id/revoke`,
`POST /v1/subjects/:id/revoke` — is a second scope, **`credentials:write`** ("Revoke
passkeys"), also off by default, so a key that can add passkeys cannot remove them unless you
said so.

## The four steps

**1. Get the human signed in.** Open the site in the browser you drive and stop. Either the
person signs in there themselves, or the site already has a session from a persistent profile.
If there is a passkey for this site already, `passkey_begin_signin` gets you in as usual. If
there is neither, say so — there is no tool that does this step.

**2. Begin.** Call `passkey_begin_enrol({ domain, signinUrl?, userName? })` — with
`subjectId` if the passkey belongs to one of your customers, and `policy: { mode: "session",
expiresAt }` if it should sign unattended for a bounded window (30 days at most; `silent` is
refused). You get a `ticket`, an `expiresAt` a few minutes out, and `shim`: a page script with
the ticket and this service's origin baked in. Navigate the signed-in browser to the site's security settings —
the page with its own "Add a passkey" button — and evaluate the shim there, as-is, before you
click anything.

**3. Click the site's button.** The shim has replaced `navigator.credentials.create()`. When the
site calls it, the page POSTs `{ ticket, origin, clientDataJSON, rpId, user }` to this service
itself, receives the attestation, and hands it to the site as if a local authenticator had made
it. You carry nothing. Watch `window.__apkEnrol.status` in the page:

| Status | Meaning |
|---|---|
| `waiting` | Installed. The site has not asked yet. |
| `creating` | The page is talking to the service. |
| `created` | The site received the attestation. |
| `failed: <reason>` | Terminal for this ticket. The reason is a plain phrase. |

Or ask the server: `passkey_enrol_status({ ticket })` reports `unused`, `created`,
`confirmed`, `withdrawn` or `expired`, with the new `credentialId` and `connector` once created.

**4. Report the verdict.** The service cannot see whether the site accepted the passkey; the
page's own success message is the only evidence. Call
`passkey_complete_enrol({ ticket, ok: true })` and the connector is **confirmed**. Call it with
`ok: false` (and a `detail` saying what the site said) and the credential is **withdrawn** —
revoked in the same transaction, so a passkey the site never registered does not linger as a
live connector.

## The shim, drawn

```
  agent's browser (signed in)                  Agent Passkey
  ───────────────────────────                  ─────────────
  site's "Add a passkey" page
   │
   │ 1. evaluate shim              ◄──────────  begin_enrol
   │    create() is now ours                    { ticket, shim }
   │
   │ 2. click "Add passkey"
   │    site calls create({rp,user,challenge})
   │
   │ 3. page POSTs {ticket, origin, ──────────►  POST /enrol/ticket
   │      clientDataJSON, rpId, user}            mint P-256 key
   │                                ◄──────────  { attestation }
   │
   │ 4. page hands attestation to the site
   │    site registers the public key
   │
   │ 5. __apkEnrol.status = "created"           GET /v1/enrol/:ticket
   │                                             → created
   │
   │ 6. site says "Passkey added"  ──────────►  POST /v1/enrol/complete
   │                                             → confirmed
```

The private key is generated at step 3 and never leaves the service. What crosses the wire is
the attestation — the public half and a signature over it — which is what the site would have
received from a local authenticator.

## The direct path

A caller that runs the WebAuthn ceremony itself — a test harness, a headless runner with its own
credential-handling — does not need the shim. `passkey_enrol_direct({ domain, origin, userName,
userHandle? })` returns the attestation in one call:

```json
{ "credentialId": "…", "attestationObject": "…", "authData": "…",
  "publicKeyDer": "…", "transports": ["internal"], "credProps": { "rk": true } }
```

The caller hands those to the site in whatever shape the site's `create()` options expect. The
connector is created immediately, in the same shape the ticket path produces; there is no
confirm step, so if the site rejects it, call `passkey_revoke` yourself.

## What the new passkey looks like

- It gets an **approval-mode** policy by default: a human is asked before every signature,
  until the owner changes it on the dashboard — or a bounded **session** policy if you asked for
  one at `begin`. Never silent.
- It is **appended to the enrolling key's accounts**, so the same key can sign in with it next.
  If that key is a session-mode key, the new credential inherits session mode.
- It appears on the dashboard's Passkeys page as **"Added via API by <agent>"**, so the owner
  can see which of their passkeys a person made and which an agent did.
- A key with `coverEnrolled` (the default for keys holding `enrol`) is covered for it
  automatically; there is nothing to re-mint.
- `passkey_revoke({ domain | credentialId })` (`credentials:write`) removes it. Every grant that
  covered it loses it at once; the audit rows stay.
- Signing many customers in? Bind each passkey to a [subject](/docs/integrators) at `begin`.

## Honest limits

- **The human signs in first.** Nothing here gets a browser past a login. If the browser you
  drive has no session on the site, stop and ask the person.
- **The site must accept a platform passkey.** The attestation reports `transports:
  ["internal"]` and `rk: true`. A site that demands a roaming key, a specific attestation
  format, or enterprise attestation will refuse it, and `ok: false` is the right report.
- **Second-factor-only sites stay out of reach.** A passkey enrolled as a second factor can be
  enrolled this way, but signing in with it later still needs the password first, which this
  service does not hold. Check the site's notes before spending the ticket.
- **One ticket, one credential.** A ticket that has `created` does nothing on a second
  `create()`; mint another. A ticket expires in a few minutes, so do step 1 before step 2.
- **Cross-origin iframes.** The shim writes `crossOrigin: false`. A site whose "add passkey"
  flow runs inside a cross-origin frame will reject the clientDataJSON. Report it.
