AuthenSeeDocs

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:

1. Enroll     POST /v1/enrollments       register the aggregate commitment   → enrollmentId
2. Challenge  POST /v1/challenges        bind a single-use challenge          → nonce, challengeBytes, layout
3. Verify     POST /v1/verify            verify the on-device ZK proof        → authResultCode (login)

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

curl -X POST https://api.authensee.com/v1/enrollments \
  -H "Authorization: Bearer sess_aG25OA7mEnbOSX2J8UwnEE-GhoRdTLxN2hVeFxhWons" \
  -H "Content-Type: application/json" \
  -d '{
    "personaId": "01917f8a-6b3e-7d4c-9a1f-2e5b8c4d6f0a",
    "schemeId": "passkey_question_v1",
    "commitment": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b",
    "passkeyOnlyCommitment": "0x2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c"
  }'
FieldTypeRequiredDescription
personaIdstring (UUID)YesThe persona to enroll, from POST /v1/personas/identify
schemeId"passkey_question_v1" | "behavior_passkey_v1" | "passkey_only_v1" | "agent_keypair_v1"YesThe scheme to register
commitmentstring (hex)YesThe aggregate scheme commitment — a BN254 field element
passkeyOnlyCommitmentstring (hex)NoThe 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

{
  "enrolled": true,
  "enrollmentId": "01917f8a-cccc-7d4c-9a1f-2e5b8c4d6f0a",
  "schemeId": "passkey_question_v1",
  "commitment": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b",
  "factors": ["image_points", "passkey"]
}
FieldTypeDescription
enrolledbooleanWhether enrollment succeeded
enrollmentIdstring (UUID)Server-side enrollment ID — pass this to /v1/challenges
schemeIdstringThe scheme registered (echoed)
commitmentstring (hex)The committed aggregate (echoed)
factorsstring[]Server-derived factor list — what a successful proof attests to

Errors

CodeStatusWhen
VALIDATION_ERROR400Unsupported or off-policy schemeId, or a commitment that isn't a valid field element
FORBIDDEN403Agent persona while your policy blocks agents
PERSONA_ALREADY_ENROLLED409The 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

curl -X POST https://api.authensee.com/v1/enrollments/add \
  -H "Authorization: Bearer sess_aG25OA7mEnbOSX2J8UwnEE-GhoRdTLxN2hVeFxhWons" \
  -H "Content-Type: application/json" \
  -d '{
    "challengeId": "01917f8a-2e5b-7d4c-9a1f-6b3e8c4d6f0a",
    "personaId": "01917f8a-6b3e-7d4c-9a1f-2e5b8c4d6f0a",
    "proof": "AAErk3hZ...base64...",
    "publicInputs": ["0x1a2b...", "0x0f3c...", "..."],
    "nullifiers": ["0x2d4e..."],
    "action": {
      "type": "enroll.add",
      "payload": {
        "newSchemeId": "passkey_only_v1",
        "newCommitment": "0x2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c"
      }
    },
    "newSchemeId": "passkey_only_v1",
    "newCommitment": "0x2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c"
  }'
FieldTypeRequiredDescription
challengeIdstring (UUID)YesFrom /v1/challenges, minted with action.type: "enroll.add"
personaIdstring (UUID)YesThe persona adding a scheme
proofstring (base64)YesProof of the persona's existing scheme
publicInputsstring[]YesFlat array of hex field elements for the existing scheme's circuit
nullifiersstring[]YesNon-zero spent nullifiers for the existing-scheme proof
actionobjectYes{ type: "enroll.add", payload: { newSchemeId, newCommitment } } — must match the challenge's bound action hash
newSchemeIdstringYesThe scheme being added — must be allowed by your current policy
newCommitmentstring (hex)YesThe 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

{
  "enrollmentId": "01917f8a-eeee-7d4c-9a1f-2e5b8c4d6f0a",
  "personaId": "01917f8a-6b3e-7d4c-9a1f-2e5b8c4d6f0a",
  "schemeId": "passkey_only_v1",
  "commitment": "0x2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c",
  "createdAt": "2026-07-09T11:05:00.000Z"
}

Errors

CodeStatusWhen
VALIDATION_ERROR400newSchemeId outside your current factor combination, or an action.payload that doesn't match the top-level fields
INVALID_PROOF400The existing-scheme proof failed verification
CONFLICT409A nullifier in the proof has already been spent
CHALLENGE_EXPIRED410The 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

curl -X POST https://api.authensee.com/v1/challenges \
  -H "Authorization: Bearer sess_aG25OA7mEnbOSX2J8UwnEE-GhoRdTLxN2hVeFxhWons" \
  -H "Content-Type: application/json" \
  -d '{
    "personaId": "01917f8a-6b3e-7d4c-9a1f-2e5b8c4d6f0a",
    "enrollmentId": "01917f8a-cccc-7d4c-9a1f-2e5b8c4d6f0a"
  }'
FieldTypeRequiredDescription
personaIdstring (UUID)YesThe persona requesting authentication
enrollmentIdstring (UUID)YesFrom the /v1/enrollments response
actionobjectNoAction 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

{
  "challengeId": "01917f8a-2e5b-7d4c-9a1f-6b3e8c4d6f0a",
  "nonce": "0x1abc9d2e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c",
  "enrollmentId": "01917f8a-cccc-7d4c-9a1f-2e5b8c4d6f0a",
  "schemeId": "passkey_question_v1",
  "challengeBytes": [12, 240, 91, 17, 203, 54, 88, 142, 7, 66, 190, 33, 251, 105, 12, 78, 160, 29, 214, 93, 41, 137, 200, 5, 118, 73, 226, 152, 61, 84, 19, 246],
  "publicInputLayout": {
    "authCommitmentIndex": 0,
    "challengeFieldIndex": 1,
    "nullifierIndices": [99],
    "totalLength": 100
  },
  "factors": ["image_points", "passkey"],
  "expiresAt": "2026-07-09T11:07:00.000Z"
}
FieldTypeDescription
challengeIdstring (UUID)The challenge's ID — pass it to /v1/verify
noncestring (hex)BN254 field element bound into the proof's challenge_field public input
enrollmentIdstring (UUID)Echoed
schemeIdstringThe scheme bound to this challenge
challengeBytesnumber[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.
publicInputLayoutobjectIndices 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.
factorsstring[]Server-derived factor list — what a successful proof attests to
expiresAtstringChallenge 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-time authResultCode that your backend exchanges via POST /v1/auth-results/exchange for 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 in token. That token is not a login credential — pass it as the x-action-token header to the endpoint the action authorizes.

Auth: session token (Authorization: Bearer sess_...), scope authenticate or full

Request

curl -X POST https://api.authensee.com/v1/verify \
  -H "Authorization: Bearer sess_aG25OA7mEnbOSX2J8UwnEE-GhoRdTLxN2hVeFxhWons" \
  -H "Content-Type: application/json" \
  -d '{
    "challengeId": "01917f8a-2e5b-7d4c-9a1f-6b3e8c4d6f0a",
    "personaId": "01917f8a-6b3e-7d4c-9a1f-2e5b8c4d6f0a",
    "proof": "AAErk3hZ...base64...",
    "publicInputs": ["0x1a2b...", "0x0f3c...", "..."],
    "nullifiers": ["0x2d4e..."]
  }'
FieldTypeRequiredDescription
challengeIdstring (UUID)YesFrom /v1/challenges
personaIdstring (UUID)YesThe persona being authenticated
proofstring (base64)YesThe ZK proof bytes generated on-device
publicInputsstring[]YesFlat 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)
nullifiersstring[]YesNon-zero spent nullifiers — the values at the indices the server published in challenge.publicInputLayout.nullifierIndices
actionobjectNoAction 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

  1. Look up the challenge and resolve its schemeId
  2. Resolve the verifier circuit and expected public-input layout from the scheme registry
  3. Check rate limits (per-IP, per-provider)
  4. Enforce your agent policy — agent personas are rejected with 403 FORBIDDEN when your policy blocks agents (the policy is read fresh, so a dashboard change applies to in-flight challenges too)
  5. Login only: enforce your current factor-combination policy — a scheme outside it fails with 409 policy_upgrade_required (see the upgrade ceremony)
  6. Assert strict public-input bindings for the scheme's circuit (for passkey_question_v1: auth_commitment at index 0, challenge_field at index 1, auth_nullifier at index 99, total length 100 — each scheme has its own layout, see Circuit architecture)
  7. Check that no claimed nullifier has been spent
  8. Verify the ZK proof via Barretenberg UltraHonk against the scheme's pinned verification key
  9. Atomically claim each nullifier — single-use, replay-protected
  10. 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)

{
  "authResultCode": "ar_bxvS6dFBzoJKW3BQxYtDm4qGh8N2LcVp",
  "providerSubject": "sub_9f3kQzWx7RmT2yLpB8vN4cD6",
  "providerId": "01906dd2-4c8a-7bb3-b7f0-5e12c9a4d833",
  "factorsVerified": ["image_points", "passkey"],
  "challengeId": "01917f8a-2e5b-7d4c-9a1f-6b3e8c4d6f0a",
  "actionType": "auth",
  "schemeId": "passkey_question_v1",
  "tokenType": "auth_result"
}

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:

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY3Rpb24iOi...",
  "personaId": "01917f8a-6b3e-7d4c-9a1f-2e5b8c4d6f0a",
  "providerId": "01906dd2-4c8a-7bb3-b7f0-5e12c9a4d833",
  "personaType": "human",
  "factorsVerified": ["image_points", "passkey"],
  "challengeId": "01917f8a-2e5b-7d4c-9a1f-6b3e8c4d6f0a",
  "actionType": "persona.link"
}

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

CodeStatusWhen
INVALID_PROOF400ZK proof verification failed
VALIDATION_ERROR400Malformed request — including client-supplied factor/circuit labels or public-input binding mismatches
UNAUTHORIZED401Invalid or expired session token
FORBIDDEN403Agent persona while your policy blocks agents
CONFLICT409A nullifier has already been spent (replay detected)
policy_upgrade_required409Login only: the proven scheme doesn't satisfy your current factor combination
CHALLENGE_EXPIRED410The challenge's TTL elapsed (default 5 minutes)
RATE_LIMITED429Too 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:

{
  "error": {
    "code": "policy_upgrade_required",
    "message": "Proven scheme does not satisfy this provider's current policy (passkey_only); allowed: passkey_only_v1",
    "factorCombination": "passkey_only",
    "allowedSchemes": ["passkey_only_v1"]
  }
}

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 authResultCode for the signed JWT
  • Personas — the action-gated link and unlink endpoints
  • Security model — why the server can verify proofs it can't read

On this page