The enrol scope: four steps, the page script that answers create(), the direct path, and the step no tool can do.
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
│ → confirmedThe 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:
{ "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 holdingenrol) 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 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"]andrk: true. A site that demands a roaming key, a specific attestation format, or enterprise attestation will refuse it, andok: falseis 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
createddoes nothing on a secondcreate(); 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.