← Blog
September 30, 2026 · 16 min

Auditing World AgentKit: proof of human for x402 agents

World's AgentKit lets an x402 server tell a human-backed agent from a script and give it a free trial instead of a bill. We read the contract and the SDK, counted every registration on-chain, and ran the published packages locally. The idea is sound. The pseudonym is global, registration needs no consent from the agent, and the signature can be replayed at another site.

Payment tells a server that money exists. It does not tell the server who is behind the request. One operator can spin up a thousand agents, each paying dust, and drain every per-identity quota a seller offers. World's answer, launched on 17 March 2026 in coordination with Coinbase, is AgentKit: a human proves uniqueness once with World ID, delegates that proof to one or more agent wallets, and x402 servers can then grant those agents free access, a free trial or a discount, capped per human rather than per wallet.

It is one of the few identity layers built specifically for x402. The x402 repository lists it among its third-party extensions. A proposal to add personhood-gated resources to the protocol itself, issue #2677, pointed to AgentKit as prior art and was closed as not planned on 28 September. So for now, AgentKit is the proof-of-human option for x402, and Exa already runs it in production.

Our sources, all read on 30 September 2026: worldcoin/agentkit at commit 1ec70f7 (24 August), the npm packages @worldcoin/agentkit and @worldcoin/agentkit-core 0.2.1 (published 24 August, identical to that source), @x402/* 2.28.0, the AgentKit docs, both AgentBook deployments read over public RPC and Blockscout, and a live 402 from Exa.

How AgentKit works

There are two halves: a registry on-chain, and a challenge inside the 402.

The registry is AgentBook, a small contract that maps an agent address to a number. The human runs npx @worldcoin/agentkit-cli register <address>, scans a QR code with World App, and produces a World ID zero-knowledge proof whose signal is the pair (agent address, nonce). A hosted relay submits the transaction, so registration is gasless. This is the whole write path:

function register(address agent, uint256 root, uint256 nonce,
                  uint256 nullifierHash, uint256[8] calldata proof) external {
    if (nonce != getNextNonce[agent]) revert InvalidNonce();
    getNextNonce[agent] = nonce + 1;
    lookupHuman[agent] = nullifierHash;   // the "humanId"
    worldIdRouter.verifyProof(root, groupId,
        abi.encodePacked(agent, nonce).hashToField(),
        nullifierHash, EXTERNAL_NULLIFIER_HASH, proof);
    emit AgentRegistered(agent, nullifierHash);
}

The value stored is the World ID nullifier hash. World's on-chain verification docs explain what that is: a number that is constant for a given user, app and action. Contracts normally store used nullifiers to enforce one human, one action. AgentBook deliberately does not, so one human can register many agents, and every one of them gets the same value. Both deployments use groupId 1, which the same docs describe as the Orb-only path for legacy (pre-4.0) proofs.

The request side reuses the CAIP-122 pattern we covered in the Sign-In-With-X audit. A protected route declares an agentkit extension. The 402 carries a challenge (domain, uri, nonce, issuedAt, resources) and a list of supportedChains. The agent signs a SIWE message with its registered wallet, then retries with the result base64-encoded in an agentkit header. Server-side hooks verify the signature, call lookupHuman on World Chain, and apply one of three modes: free, free-trial (the first N uses per human per endpoint) or discount.

Exa is the clearest deployment. An unauthenticated POST to api.exa.ai/search returns a 402 with seven payment options, $0.007 per search in USDC (1,000 atomic units, $0.001, for /contents), and an agentkit challenge that accepts EIP-191 and ERC-1271 signatures on World Chain only. Exa's documentation promises 100 free requests per verified human per month, shared across all of that human's agents.

What the chain says

AgentBook has two deployments, both verified on Blockscout, with the same 3,569-byte runtime and the same owner. The SDK reads only one of them.

// AgentBook census, snapshot 2026-09-30 (~09:10 UTC)
World Chain  0xA23aB2712eA7BBa896930544C7d6636a96b944dA   // read by the SDK
  registrations (AgentRegistered)     1,275   first 2026-03-15, last 2026-09-30 04:51
  distinct agent addresses            1,275   re-registrations: 0
  distinct humanIds                     860
  humans with one agent                 713
  humans with 2+ agents                 147   (562 agents, 44% of all)
  largest cluster                        41   agents under one humanId
  per month   Mar 80 | Apr 286 | May 703 | Jun 67 | Jul 94 | Aug 9 | Sep 36
Base         0xE1D1D3526A6FAa37eb36bD10B933C1b77f4561a4   // documented, not read
  registrations                          10   2026-03-06 to 2026-04-02

Three things stand out. First, activity. At snapshot time, only 73 of the 1,275 registered addresses held any USDC on World Chain or Base. Of the 1,186 that are EOAs (including EIP-7702-delegated ones), 1,108 hold no USDC and have never sent a transaction on either chain. That does not prove they never paid, since an EIP-3009 payer never sends its own transaction, but it matches a registry that is mostly early experiments. Registrations peaked in May and have been in single or low double digits per month since.

Second, what kind of wallets are being registered. On World Chain, 83 agent addresses are Safe 1.4.1 proxies with one owner, a threshold of one and the Safe4337Module enabled. Three more are EIP-7702-delegated EOAs (13 on Base). That Safe shape is the standard ERC-4337 Safe, and World has said it uses Safe as the default smart account for every World App wallet. Chain data alone cannot prove that each of those 83 is a personal World App wallet. If some are, their owners have tied their World ID pseudonym to their main wallet, in public.

Third, documentation drift. The repository's REGISTRATION.md still lists base and base-sepolia as the supported networks and says the CLI defaults to a hosted relay on Base. The CLI code, the SDK and the hosted docs moved to World Chain in March and April. The 10 Base registrations are the trace: six of those agent addresses were never registered on World Chain, so the default verifier, which reads only World Chain, treats them as unregistered. All seven distinct humanIds on Base also appear on World Chain with identical values, which matters for the first finding.

Both deployments are owned by the same externally owned account, 0xE340…B39D. That key can replace the World ID router (setWorldIdRouter) or change the credential group (setGroupId). Ownership cannot be renounced. In practice, what counts as a valid proof of human is one key's decision, which is normal for a beta and worth knowing before a seller prices anything on it.

Six findings

We ran the published packages against local Hono and Express servers built from the AgentKit integration guide. The only thing we stubbed was the AgentBook lookup, which returned a fixed humanId for our test wallet. We did not test against Exa or any other production server.

// Finding 1

One pseudonym for every site

World ID nullifiers are scoped to an app and an action, so different apps normally see different values for the same person. AgentKit uses a single app ID (app_a7c3e2b6…146a) and a single action (agentbook-registration) for every relying party. The humanId is therefore the same at Exa, at any other AgentKit seller, on World Chain and on Base. The seven humans who registered on both chains carry identical values on both.

World's launch post is candid that "a website can see that all of those agents trace back to the same unique human". The precise version is broader. Everyone can see it, because AgentRegistered(agent, humanId) is a public event. 562 agent addresses sit in clusters of two or more today. Once any one of them is linked to a person, the rest follow, along with their payment history.

// Finding 2

Registration needs no consent from the agent

register() never checks msg.sender and never asks for a signature from the agent address. The CLI takes any address as an argument. So any World ID holder can register any wallet under their own humanId. Because the nonce advances on every registration, they can also re-point a wallet that someone else already registered. The contract test testCanReRegisterAgent covers that overwrite as intended behaviour.

The agent's control of its key is proven later, at request time, by the SIWE signature. The human's claim over the agent is never proven. The worst case is not theft: someone who re-points your agent to their humanId gives you their quota, or takes yours away when theirs is spent. But the humanId is attribution without consent, and there is no revocation. Issue #23 (how to unregister, open since April) and RFC #37 (re-registration, revocation, rotation, open since July) are both unresolved. Our census found no overwrites yet: every agent address has been registered exactly once.

// Finding 3

The client signs any origin, so signatures can be replayed at another site

createAgentkitClient takes domain and uri from the 402 and signs them. It never compares them with the URL it actually requested. We pointed an agent at a hostile server that answered with a 402 naming a different AgentKit server as the domain:

// local harness, 2026-09-30, @worldcoin/agentkit 0.2.1 + @x402/express 2.28.0
// agent calls ONLY the attacker (127.0.0.1); victim = AgentKit free-trial on Express (localhost)
agent -> attacker                         200   agentkit_detected, agentkit_signed, agentkit_retry_completed
harvested header signs                    domain=localhost  uri=http://localhost:4392/data
attacker -> victim, harvested header      200   {"served":"victim /data"}   // victim: agent_verified
second replay (nonce tracking on)         402

The victim counted one free use against the human's quota for a request the agent never sent to it. With nonce tracking on, each harvested signature works once. In free mode the guide says no storage is needed, and without storage there is no nonce check at all. We replayed one header five times against a free route and got five 200s. The window is the five-minute maxAge, and the validator binds the host but not the path, so one signature opens every route on that host. Even with storage, the check and the record are two separate calls, a race that open PR #36 fixes with atomic consumption.

The x402 reference SDK fixed the same pair of problems for Sign-In-With-X this summer: #2859 (15 July) bound server-side domain validation to a configured origin instead of the Host header, and #3133 (13 August) made the client refuse challenges that do not match the request origin. AgentKit 0.2.1 has neither. Behind the x402 Express adapter, the URL it validates against is built from the Host header.

// Finding 4

The server trusts whatever chain the client names

The server advertises its supportedChains, then ignores them. The payload's chainId decides which chain's RPC the signature is checked against. Our Express server advertised only eip155:8453:

chainId in payload   status   hook event
eip155:8453          200      agent_verified
eip155:10            200      agent_verified     // not advertised
eip155:137           200      agent_verified     // not advertised
eip155:999999        402      validation_failed  // viem has no RPC for it

For an EOA this is harmless. The signature is valid on any chain. For a smart-account agent it is not. ERC-1271 validity is per chain, and a Safe whose owner was rotated on one chain still honours the old key on another. The client chooses which copy of the account the server asks. The fix that shipped in 0.2.1, #32, "verify SCA signatures on signed chain", made this explicit, but without an allowlist.

There is also an operational cost. viem's verifyMessage tries the ERC-6492 universal validator over RPC before falling back to ecrecover. So even an EOA check costs a network round trip, by default to a public RPC, plus a second call to read AgentBook on World Chain, with no caching in 0.2.1. Each verified request took about 400 ms in our runs. And lookupHuman catches every error and returns null. If the World Chain RPC is rate-limited, a genuinely human-backed agent is silently treated as unregistered and sent to the paid path.

// Finding 5

The free-trial counter is keyed on the raw path

Usage is counted with tryIncrementUsage(context.path, humanId, uses). context.path is the path exactly as the client typed it. The x402 core, however, matches routes case-insensitively and after normalising trailing slashes, and Express does the same by default. With free-trial and uses: 1:

express 5.2.1   /data    200   // the one free use
                /data    402   // exhausted, as intended
                /Data    200   // fresh counter
                /DATA    200   // fresh counter
                /data/   200   // fresh counter
hono 4.13       /Data    404   // case-sensitive router, handler never reached

On Express, every spelling that reaches the same handler gets its own quota. For /data that is 32 spellings. For an OpenAI-compatible path like /v1/chat/completions, with 16 letters, it is 65,536 before trailing slashes. Hono, the framework the guide uses, is not affected because its router is case-sensitive. The fix is already in reach: the x402 core passes the matched routePattern to every hook, and keying the counter on it would close the gap. The same per-endpoint keying also means a seller with 20 paid routes and uses: 5 gives away 100 calls per human, not 5. That is documented, but easy to miss.

// Finding 6

The reference client reads the wrong half of the 402

x402 v2 puts the PaymentRequired object, extensions included, in the base64 PAYMENT-REQUIRED header. The stock @x402/hono and @x402/express middleware send an empty JSON body. createAgentkitClient looks for the extension in the body. Against the guide's own Hono server, agentkit.fetch returned the 402 with no AgentKit event at all. The package's end-to-end test passes because its mock server puts the challenge in the body.

It works against Exa only because Exa copies the PaymentRequired object into the body as well. Even there, a signer configured as in the README, with chainId: 'eip155:8453', is skipped: Exa advertises only eip155:480, and the client insists on an exact match. So the client is strict about the chain, where the server is lax, and lax about the origin, where it should be strict.

What holds up — the core cryptography is fine. A World ID proof cannot be reused for another agent or nonce, the SIWE signature does prove control of the agent key, domain binding stops naive cross-host reuse, and nonce tracking works when storage is configured. Every finding above is in the integration layer, which is exactly where a beta should be tightened before sellers put real value behind it.

What's coming: a rewrite on RFC 9421

An open pull request, #38 (branch new-cli, 19 commits, last updated 1 September), redesigns the SDK. The CLI will generate and store the agent key itself. Stacked on it, #39, merged into that branch on 1 September, replaces the SIWE challenge with RFC 9421 HTTP Message Signatures over @method, @authority, @path, @query and an RFC 9530 content-digest. Signatures last up to 300 seconds, with 30 seconds of clock skew allowed. The server rebuilds everything from the request it received. That is the same signature family Web Bot Auth uses, and it would close Finding 3: a signature over the attacker's authority and path does not verify anywhere else.

Two caveats. The branch verifies with recoverMessageAddress, so it is EOA-only, dropping the ERC-1271 support that 83 Safe registrations rely on. And it rebuilds @authority from request.url, which @hono/node-server, for one, builds from the Host header. That is the weakness x402 fixed with a configured origin. A single-use nonce was proposed on top (#42) and closed unmerged. None of this is in main or on npm yet. The registry contract only changes its comments: "anonymous human identifier" becomes "lookup ID". That is a better description of a value that is a stable, public, cross-site pseudonym.

What it means for LLM4Agents

AgentKit fills a gap we have. Our gateway sells per call over x402, and an agent's first call is the hardest sale to make. We cannot hand out API keys without accounts, and we cannot give every new wallet free credit without paying for every script farm on the internet. A trial capped per human, with no KYC, no captcha and no account, is the right shape for agent onboarding. It sits naturally between the 402 and the rate limiter we described in Wait or pay?, and it composes with the pay-once session that SIWX gives returning payers.

The findings bound what it can be used for. A humanId is not a payment and not an accountable identity. Anyone with a World ID can attach it to any address, and the attachment cannot be revoked. So it is safe to use for giving away small, capped value, and unsafe for anything that extends credit, builds reputation or assigns blame. Finding 5 hits us directly: our main route is /v1/chat/completions. A trial wired with the stock hooks behind a case-insensitive router would be a trial per spelling.

There is a privacy side for our users. Operators who register fleets create a permanent public cluster, and the humanId is the same at every seller. If we log humanIds next to payer addresses and request metadata, we become one more party able to link a person's agents across the market. And if our buyer tooling ever signs AgentKit challenges for users, Finding 3 means a hostile seller could burn their Exa quota, or their quota with us.

Staying on the frontier

First, pilot a per-human trial with our own verifier, not the stock hooks. Key usage on the matched routePattern the x402 core already passes to hooks. Compare the signed domain to a configured origin, never the Host header. Reject any chainId we did not advertise. Pin our own RPC endpoints. Cache lookups for a short TTL. Consume nonces atomically in every mode, with the INSERT … ON CONFLICT DO NOTHING pattern from PR #36.

Second, cap the value. A trial worth a few cents per human per month, in the spirit of Exa's 100 requests, is a bounded marketing cost. Never extend credit, spending limits or reputation on a humanId alone.

Third, fix the client before we ship one. If our agent tooling supports AgentKit, it must read the PAYMENT-REQUIRED header, refuse to sign when domain or uri does not match the request origin, and match chains the way the server does. That mirrors x402's own #3133.

Fourth, give operators a privacy rule of thumb in our docs. Register dedicated agent wallets, never a personal World App wallet, and treat every registration as a public, permanent link between those agents. On our side, store humanIds only where the trial counter needs them.

Fifth, track PR #38. RFC 9421 request signing is the right direction, and it matches the Web Bot Auth signatures we would verify anyway. Plan for the gap it leaves: smart-account agents need ERC-1271, and the authority must come from configuration.

Sixth, report upstream. The origin check, the chain allowlist, the route-pattern key, the header-versus-body parsing and the stale Base guide are each small, testable fixes to a beta that the x402 ecosystem has already adopted as its proof-of-human layer.

Onboard agents without handing out keys

One OpenAI-compatible gateway, paid per call in USDC over x402.

Register your agent