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.
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| Status | Meaning | Terminal |
|---|---|---|
| PENDING | Waiting for a proof. Failed attempts leave the challenge PENDING. | No |
| VERIFIED | A proof was accepted. The result and token are attached. | Yes |
| REJECTED | The attempt limit (10) was exceeded. A new challenge is required. | Yes |
| EXPIRED | expires_at passed before a proof was accepted. | Yes |
| REVOKED | Reserved status value; not set by any current API path. | Yes |
| ERROR | Reserved 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
| Parameter | Value | Notes |
|---|---|---|
| Challenge TTL | 120 s default, 30–300 s | Set with ttl when creating the challenge |
| Registration challenge TTL | 15 min | Issued when a key is registered; bound to that key |
| Registration token TTL | 15 min | reg_… tokens; single use |
| Token TTL | 10 min | exp = iat + 600 |
| Signature skew | 60 s | Accepted window for the proof's created |
| Proof attempts per challenge | 10 | The 11th attempt rejects the challenge permanently |
| Active keys per agent | 5 | Pending keys count; revoked keys do not |
| Agents per account | 50 | |
| Sites per account | 25 | |
| Allowed origins per site | 20 | |
| Request body | 32,000 bytes | BODY_TOO_LARGE above this |
What the server checks
For a registry proof on POST /api/v1/verify, in this order:
- Body shape (
SIGNATURE_MALFORMED), challenge exists (CHALLENGE_NOT_FOUND), challenge is PENDING (CHALLENGE_EXPIRED,CHALLENGE_CONSUMED,CHALLENGE_NOT_PENDING). - Attempt counter incremented; above 10 the challenge becomes REJECTED and the call returns
RATE_LIMITED. challenge_idin the body matches the challenge (CHALLENGE_MISMATCH).- Agent exists and is not revoked or disabled (
AGENT_UNKNOWN,AGENT_REVOKED,AGENT_DISABLED); an agent still pending its first registration proof yieldsKEY_PENDING. - Key belongs to the agent and is active (
KEY_UNKNOWN,KEY_REVOKED,KEY_PENDING). createdis within 60 s of the server clock and not beforeissued_at − 60(SIGNATURE_EXPIRED).- The signature base is rebuilt from the server's stored challenge, never from client fields, and the Ed25519 signature is verified (
SIGNATURE_INVALID). - 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/simulatecompletes the challenge with the agentagent_simulated_test("Simulated agent (test mode)") and methodsimulated. The widget exposes this as the Simulate agent button.- Tokens carry
inh.mode = "test"andPOST /api/v1/tokens/verifyrejects them withTOKEN_TEST_MODEunlessallow_test: trueis 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.
| Endpoint | Per network / min | Other buckets |
|---|---|---|
| POST /challenges | 240 | 600 per site; 30 unknown keys |
| GET /challenges/:id | 900 | 240 per challenge |
| POST /challenges/:id/simulate | 120 | |
| POST /verify | 240 | 600 per agent, 600 per site, 30 per challenge; 10 attempts |
| POST /tokens/verify | 600 | 600 per site key |
| POST /registration/keys | 30 | 10 per agent |
| GET /health | 60 | monitoring is allowed inside this cap |
| GET JWKS | 300 | per instance; responses are cacheable |
| Management (bearer) | 600 | 300 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.