Introduction

How verification works

The challenge lifecycle: PENDING to VERIFIED, REJECTED or EXPIRED, with timings, limits and what the server checks at each step.

State machine

Every challenge starts PENDING and ends in exactly one terminal state.

States
PENDING ──(valid proof)──────────▶ VERIFIED   token issued, challenge consumed
   │
   ├──(11th proof attempt)────────▶ REJECTED   consumed without a token
   │
   └──(expires_at reached)────────▶ EXPIRED    no proof accepted after this
StatusMeaningTerminal
PENDINGWaiting for a proof. Failed attempts leave the challenge PENDING.No
VERIFIEDA proof was accepted. The result and token are attached.Yes
REJECTEDThe attempt limit (10) was exceeded. A new challenge is required.Yes
EXPIREDexpires_at passed before a proof was accepted.Yes
REVOKEDReserved status value; not set by any current API path.Yes
ERRORReserved status value; not set by any current API path.Yes

Expiry is applied lazily: a PENDING challenge whose expires_at has passed is marked EXPIRED the next time it is read or a proof arrives. A background sweep also expires stale challenges roughly once a minute.

Timings and limits

ParameterValueNotes
Challenge TTL120 s default, 30–300 sSet with ttl when creating the challenge
Registration challenge TTL15 minIssued when a key is registered; bound to that key
Registration token TTL15 minreg_… tokens; single use
Token TTL10 minexp = iat + 600
Signature skew60 sAccepted window for the proof's created
Proof attempts per challenge10The 11th attempt rejects the challenge permanently
Active keys per agent5Pending keys count; revoked keys do not
Agents per account50
Sites per account25
Allowed origins per site20
Request body32,000 bytesBODY_TOO_LARGE above this

What the server checks

For a registry proof on POST /api/v1/verify, in this order:

  1. Body shape (SIGNATURE_MALFORMED), challenge exists (CHALLENGE_NOT_FOUND), challenge is PENDING (CHALLENGE_EXPIRED, CHALLENGE_CONSUMED, CHALLENGE_NOT_PENDING).
  2. Attempt counter incremented; above 10 the challenge becomes REJECTED and the call returns RATE_LIMITED.
  3. challenge_id in the body matches the challenge (CHALLENGE_MISMATCH).
  4. Agent exists and is not revoked or disabled (AGENT_UNKNOWN, AGENT_REVOKED, AGENT_DISABLED); an agent still pending its first registration proof yields KEY_PENDING.
  5. Key belongs to the agent and is active (KEY_UNKNOWN, KEY_REVOKED, KEY_PENDING).
  6. created is within 60 s of the server clock and not before issued_at − 60 (SIGNATURE_EXPIRED).
  7. The signature base is rebuilt from the server's stored challenge, never from client fields, and the Ed25519 signature is verified (SIGNATURE_INVALID).
  8. The challenge is consumed atomically; a concurrent duplicate gets CHALLENGE_CONSUMED.

Each rejection is recorded as an event with its failure code, visible in the dashboard. Successes record the agent, method, mode and origin. See Verification flow for the full sequence including token issuance.

Test mode

Challenges created with a site_test_… key have mode: "test". Two things change:

  • POST /api/v1/challenges/:id/simulate completes the challenge with the agent agent_simulated_test ("Simulated agent (test mode)") and method simulated. The widget exposes this as the Simulate agent button.
  • Tokens carry inh.mode = "test" and POST /api/v1/tokens/verify rejects them with TOKEN_TEST_MODE unless allow_test: true is sent.

Real agents can also sign test-mode challenges; the resulting token is still a test token. Live challenges cannot be simulated (TEST_MODE_REQUIRED).

Rate limits

Limits are per-minute buckets. When exceeded the API returns 429 with code RATE_LIMITED and a Retry-After: 60 header. The attempt-limit rejection on /verify also uses RATE_LIMITED but is permanent for that challenge and carries no Retry-After.

EndpointPer network / minOther buckets
POST /challenges240600 per site; 30 unknown keys
GET /challenges/:id900240 per challenge
POST /challenges/:id/simulate120
POST /verify240600 per agent, 600 per site, 30 per challenge; 10 attempts
POST /tokens/verify600600 per site key
POST /registration/keys3010 per agent
GET /health60monitoring is allowed inside this cap
GET JWKS300per instance; responses are cacheable
Management (bearer)600300 per user; 60 for invalid sessions

"Network" is an opaque hash of the caller's address, used only for bucketing. On the website, that address is Cloudflare's CF-Connecting-IP when the header is present. Direct calls to the verification API use the gateway-appended X-Forwarded-For entry, or a signed attribution from the website proxy. A verified agent uses the same budgets as any other caller. Passing verification does not open a path to mint or verify without limit.

The website also throttles HTML, widget.js, agent.mjs and /api/v1 before those requests are rendered or proxied. Pages and static files have higher ceilings than challenge mint, verify and key registration. The matrix is in the repository file SECURITY-RATE-LIMITS.md.