Enrollment & verification
The ceremony pipeline — register a scheme commitment, bind a challenge, and verify a zero-knowledge proof. Includes the policy upgrade ceremony.
These endpoints form the ceremony pipeline the hosted flow runs on the user's behalf. They are session-token calls; your backend never calls them directly, but everything your users' proofs attest to is defined here.
The pipeline is always three steps:
Every enrollment is registered against one scheme, and the server stores exactly one aggregate commitment per enrolled scheme per persona. Factor and circuit semantics are derived from the schemeId — never supplied by the client.
POST /v1/enrollments
Bootstrap-enroll a persona's first scheme. The SDK computes the scheme's aggregate commitment on-device — for passkey_question_v1, auth_commitment = Poseidon2(question_root, passkey_commitment, 0, 0) — and only that single field element crosses the wire.
This is the only enrollment endpoint that doesn't require a proof, and it's only valid for a persona with no current enrollment. Adding a scheme to an already-enrolled persona goes through POST /v1/enrollments/add.
Auth: session token (Authorization: Bearer sess_...), scope enroll or full
Request
| Field | Type | Required | Description |
|---|---|---|---|
personaId | string (UUID) | Yes | The persona to enroll, from POST /v1/personas/identify |
schemeId | "passkey_question_v1" | "behavior_passkey_v1" | "passkey_only_v1" | "agent_keypair_v1" | Yes | The scheme to register |
commitment | string (hex) | Yes | The aggregate scheme commitment — a BN254 field element |
passkeyOnlyCommitment | string (hex) | No | The passkey_only_v1 aggregate for the same passkey. When present, the server opportunistically registers the persona's passkey-only scheme alongside the primary one in the same bootstrap call. Not policy-gated at write time — it's only usable at login when your current policy is passkey_only. |
Response
| Field | Type | Description |
|---|---|---|
enrolled | boolean | Whether enrollment succeeded |
enrollmentId | string (UUID) | Server-side enrollment ID — pass this to /v1/challenges |
schemeId | string | The scheme registered (echoed) |
commitment | string (hex) | The committed aggregate (echoed) |
factors | string[] | Server-derived factor list — what a successful proof attests to |
Errors
| Code | Status | When |
|---|---|---|
VALIDATION_ERROR | 400 | Unsupported or off-policy schemeId, or a commitment that isn't a valid field element |
FORBIDDEN | 403 | Agent persona while your policy blocks agents |
PERSONA_ALREADY_ENROLLED | 409 | The persona already has a current enrollment — use /v1/enrollments/add |
Enrolling under today's policy doesn't protect a persona from a later policy tightening — see the upgrade ceremony for what happens at the next login.
POST /v1/enrollments/add
Add a scheme to an already-enrolled persona. This is the proof-gated counterpart to the bootstrap endpoint: the caller first mints a challenge with action.type: "enroll.add" and action.payload: { newSchemeId, newCommitment }, then proves control of an existing scheme (any enrolled scheme — commonly the universal passkey-only factor) against that challenge. It powers the policy upgrade ceremony.
Auth: session token (Authorization: Bearer sess_...), scope authenticate or full
Request
| Field | Type | Required | Description |
|---|---|---|---|
challengeId | string (UUID) | Yes | From /v1/challenges, minted with action.type: "enroll.add" |
personaId | string (UUID) | Yes | The persona adding a scheme |
proof | string (base64) | Yes | Proof of the persona's existing scheme |
publicInputs | string[] | Yes | Flat array of hex field elements for the existing scheme's circuit |
nullifiers | string[] | Yes | Non-zero spent nullifiers for the existing-scheme proof |
action | object | Yes | { type: "enroll.add", payload: { newSchemeId, newCommitment } } — must match the challenge's bound action hash |
newSchemeId | string | Yes | The scheme being added — must be allowed by your current policy |
newCommitment | string (hex) | Yes | The new scheme's aggregate commitment |
The proof-bound action.payload must match the top-level newSchemeId / newCommitment exactly, or the request is rejected — this stops a caller from proving one commitment and registering a different, unauthorized one.
Response
Errors
| Code | Status | When |
|---|---|---|
VALIDATION_ERROR | 400 | newSchemeId outside your current factor combination, or an action.payload that doesn't match the top-level fields |
INVALID_PROOF | 400 | The existing-scheme proof failed verification |
CONFLICT | 409 | A nullifier in the proof has already been spent |
CHALLENGE_EXPIRED | 410 | The enroll.add challenge's TTL elapsed |
This endpoint — and persona.link / factor.update / persona.unlink, all driven through the same action-typed challenge/verify pipeline — is exempt from the login policy check: it authorizes control of an existing factor, not a login.
POST /v1/challenges
Bind a single-use challenge to an enrollment. Returns a cryptographic nonce, challengeBytes for the WebAuthn ceremony, and the public-input layout the SDK uses to extract nullifiers.
Auth: session token (Authorization: Bearer sess_...), scope authenticate or full
Request
| Field | Type | Required | Description |
|---|---|---|---|
personaId | string (UUID) | Yes | The persona requesting authentication |
enrollmentId | string (UUID) | Yes | From the /v1/enrollments response |
action | object | No | Action binding: { type, payload }. type is one of auth (default), enroll.add, persona.link, factor.update, persona.unlink; every type except auth requires a payload, which is hashed into the proof. |
Response
| Field | Type | Description |
|---|---|---|
challengeId | string (UUID) | The challenge's ID — pass it to /v1/verify |
nonce | string (hex) | BN254 field element bound into the proof's challenge_field public input |
enrollmentId | string (UUID) | Echoed |
schemeId | string | The scheme bound to this challenge |
challengeBytes | number[32] | 32 random bytes — feed into navigator.credentials.get(...) for the WebAuthn ceremony. Unused by agent_keypair_v1, which signs the bytes directly with no WebAuthn envelope. |
publicInputLayout | object | Indices the verifier expects in the flat public-input array. The SDK uses nullifierIndices to extract the nullifiers it sends on /v1/verify, so circuit reorderings never require an SDK release. |
factors | string[] | Server-derived factor list — what a successful proof attests to |
expiresAt | string | Challenge expiration (default TTL: 5 minutes) |
POST /v1/verify
Submit a ZK proof for verification. The shape of a successful response depends on the challenge's action type:
- Login (
action.type: "auth", or omitted — the common case): the server does not return a JWT directly. It mints a one-timeauthResultCodethat your backend exchanges viaPOST /v1/auth-results/exchangefor the signed auth-result JWT. - Every other action (
enroll.add,persona.link,factor.update,persona.unlink): the server returns a short-lived action-scoped JWT directly intoken. That token is not a login credential — pass it as thex-action-tokenheader to the endpoint the action authorizes.
Auth: session token (Authorization: Bearer sess_...), scope authenticate or full
Request
| Field | Type | Required | Description |
|---|---|---|---|
challengeId | string (UUID) | Yes | From /v1/challenges |
personaId | string (UUID) | Yes | The persona being authenticated |
proof | string (base64) | Yes | The ZK proof bytes generated on-device |
publicInputs | string[] | Yes | Flat array of hex field elements. Length depends on the scheme's circuit (100 for passkey_question_auth / passkey_only_auth, 101 for behavior_auth_passkey, 36 for agent_keypair_auth) |
nullifiers | string[] | Yes | Non-zero spent nullifiers — the values at the indices the server published in challenge.publicInputLayout.nullifierIndices |
action | object | No | Action binding, matching the challenge's action |
No client-supplied factor or circuit labels
The server rejects factorType, circuitType, and factorsAttested keys to prevent verifier downgrade or factor-claim swap. All three are derived from the schemeId stored alongside the challenge.
What the server checks
- Look up the challenge and resolve its
schemeId - Resolve the verifier circuit and expected public-input layout from the scheme registry
- Check rate limits (per-IP, per-provider)
- Enforce your agent policy — agent personas are rejected with
403 FORBIDDENwhen your policy blocks agents (the policy is read fresh, so a dashboard change applies to in-flight challenges too) - Login only: enforce your current factor-combination policy — a scheme outside it fails with
409 policy_upgrade_required(see the upgrade ceremony) - Assert strict public-input bindings for the scheme's circuit (for
passkey_question_v1:auth_commitmentat index 0,challenge_fieldat index 1,auth_nullifierat index 99, total length 100 — each scheme has its own layout, see Circuit architecture) - Check that no claimed nullifier has been spent
- Verify the ZK proof via Barretenberg UltraHonk against the scheme's pinned verification key
- Atomically claim each nullifier — single-use, replay-protected
- For a login, mint the
authResultCode; for every other action, issue the action-scoped JWT. Either way, the verified factor list is derived from the scheme, never from client input.
Success response (login)
There is no token or personaId in this response. authResultCode is single-use and short-lived — exchange it server-side via POST /v1/auth-results/exchange. This is also exactly what lands as authResultCode on your hosted-flow callback.
Success response (other actions)
For enroll.add, persona.link, factor.update, and persona.unlink, the response instead carries a short-lived action-scoped JWT directly:
This token is signed separately from the auth-result JWT (HS256, short TTL) and exists only to authorize the specific action it was minted for — pass it as x-action-token to that action's endpoint, for example POST /v1/personas/:personaId/provider-links. It is never returned for a login and must never be treated as a login credential.
Errors
| Code | Status | When |
|---|---|---|
INVALID_PROOF | 400 | ZK proof verification failed |
VALIDATION_ERROR | 400 | Malformed request — including client-supplied factor/circuit labels or public-input binding mismatches |
UNAUTHORIZED | 401 | Invalid or expired session token |
FORBIDDEN | 403 | Agent persona while your policy blocks agents |
CONFLICT | 409 | A nullifier has already been spent (replay detected) |
policy_upgrade_required | 409 | Login only: the proven scheme doesn't satisfy your current factor combination |
CHALLENGE_EXPIRED | 410 | The challenge's TTL elapsed (default 5 minutes) |
RATE_LIMITED | 429 | Too many requests — including an agent persona exceeding your per-agent budget |
Provider policy and the upgrade ceremony
Your factor-combination policy gates exactly two points: enrollment (an off-policy schemeId is rejected at POST /v1/enrollments) and login (POST /v1/verify with action.type: "auth", or omitted). Everywhere else — enroll.add, persona.link, factor.update, persona.unlink — the caller proves control of a scheme it already has, which is never blocked by a later policy change.
At login, if the persona's proven scheme isn't in your current factor combination's allowed set, /v1/verify returns HTTP 409:
factorCombination and allowedSchemes are machine-readable — the hosted flow keys its upgrade UI on error.code === 'policy_upgrade_required' and uses allowedSchemes to decide which factor to collect next. The recovery path is POST /v1/enrollments/add: prove the persona's existing (off-policy) scheme once, bind the new scheme's commitment into that same proof, and the persona qualifies on its next login. Nothing about the old enrollment is deleted or mutated — the persona simply gains an additional, on-policy scheme.
Next
- Auth results — exchange the
authResultCodefor the signed JWT - Personas — the action-gated link and unlink endpoints
- Security model — why the server can verify proofs it can't read