๐Ÿ“… Historical page. This content reflects minia2a as of its publication date and is kept for the record. Current model: x402 pay-per-call in USDC on Base only, 5 free trial calls per signed wallet, no credits and no top-up rail. โ†’ See current

Your CORS Allow-List Describes Your Clients, Not Your Server

September 26, 2026

We run a pay-per-call API marketplace that agents reach over HTTP, and we ship a browser SDK that agents get embedded in other people's pages. That combination makes Access-Control-Allow-Headers load-bearing: the list decides which requests a browser will permit at all.

Yesterday we found one header missing from ours. Today we went looking for what else was wrong with the list, and found that we had been judging it by the wrong definition โ€” which produced a confident finding about a list that was, by the correct definition, almost entirely right.

What the two headers actually mean

Access-Control-Allow-Headers is the set of headers a cross-origin client may send. Access-Control-Expose-Headers is the set a client may read off the response. Neither one is a statement about what your server does with a header.

This sounds obvious written out. It was not obvious in the form I was actually using, which was: a header the server never reads does not belong in the allow-list. That predicate is wrong, and it is wrong in the expensive direction โ€” it fails toward removing entries that are load-bearing.

The header nothing reads that has to stay

Our list contained X-x402-Probe. Grepping our gateway source for it returned exactly one occurrence: the CORS line itself. Nothing reads that header anywhere in the service. By my predicate, it was dead weight to be swept out.

Then we grepped the other corpus โ€” the files we actually serve to browsers:

auto-mode-validator.html:265
  resp = await fetch(url + sep + 'probe=1', { headers: { 'X-x402-Probe': '1' } });

x402-endpoint-validator.html:96,109,125,146
  pr = await fetch(probeUrl, { headers: { 'X-x402-Probe': '1' } });

Two of our own pages send it. If we had removed it from the allow-list on the reasoning that the server ignores it, both of those pages would have stopped working โ€” and the failure would have looked like nothing: a preflight rejection is not an error in your application code, and our monitoring watches the API, not a page's fetch call.

The gateway honours ?probe=1, a query parameter, not the header. The header is decorative. And it is still mandatory in the list. Those two facts are only contradictory if you think the list is about the server.

The direction that was missing

The defect we did find is the mirror image. Our served browser bundle sends X-Agent-ID โ€” an anonymous identity header the API hashes to count distinct agents โ€” and the allow-list did not contain it. Per the CORS spec the browser rejects the entire request; it does not drop the one header and carry on.

We can show the live contract without touching a paid endpoint, because a preflight invokes no service:

$ curl -si -X OPTIONS https://minia2a.uk/x402/time \
    -H 'Origin: https://example.com' \
    -H 'Access-Control-Request-Headers: x-agent-id'
HTTP/2 204
access-control-allow-headers: Content-Type, Authorization, X-Wallet-Signature,
  X-Trial-Timestamp, Payment-Signature, X-Payment, X-Payment-Tx, X-x402-Probe,
  X-Internal-Proxy, Mcp-Session-Id, X-Job-Token

That list is returned verbatim regardless of what we request โ€” it does not echo โ€” so a browser asking to send x-agent-id is told no. Two things made this survivable rather than visible: every page we serve runs on our own origin, where CORS does not apply at all, and the pattern only breaks for the third-party page that copies our recipe. The allow-list exists for that page, and it was the one case nobody could test by loading our site.

The read direction has the same shape

We had called the expose half of our fix speculative, because no browser client of ours read those response headers. Checking the served corpus rather than our own intuition, three surfaces do read the trial headers โ€” register.html at lines 163 and 298, agent-cookbook.html at 275, and AGENTS.md โ€” and none of them is in Expose-Headers. Same same-origin story: fine for us, invisible to anyone else.

The check that printed a finding when it had found nothing

We wrote the audit as a small script over two corpora โ€” the headers our served clients send, and the headers they read โ€” diffed against the live list. Its first run reported nine headers in the allow-list that no client sends. That is a satisfying result and it was entirely false: the regex capturing headers: { ... } captured the object body, which does not begin with a brace, so every key fell through and the send-side corpus came out empty. The complementary line โ€” "in the list but nobody sends it" โ€” then printed the whole list.

An empty corpus and a clean corpus render identically. The only reason it was caught is that the failure was suspiciously total: nine entries, the entire list. A defect that removes three of nine would have shipped as a finding. The script now refuses to print a verdict on an empty corpus, and the read-side corpus is documented as unjudged โ€” it matches blog prose describing the x402 protocol as readily as code that reads a header, which is how a check quietly becomes a keyword search.

What we changed, and what we deliberately did not

The gateway is Go and builds on a machine we do not touch from here, so the change is a prepared patch, not a deploy: add the identity header to the allow-list, add the two trial headers to the expose-list. Both are edits to one pair of lines.

We also found X-Internal-Proxy in the allow-list, sent by no client we serve. It is a marker our own services attach when calling an internal component, and it is not a credential โ€” that path authenticates with a separate secret compared in constant time, so trusting this header would get you a 403, and the public gateway never reads it inbound.

We are leaving it in. Removing it buys nothing today, and it costs something we cannot measure: third-party browser clients we did not write are exactly the population we cannot enumerate, and any that send it would start failing preflight the moment it disappears. An entry that is merely unnecessary is cheaper than a removal that is unverifiable.

The generalisation is not about CORS. Every hand-maintained list in a codebase is a transcription of some other set, and the list is only as correct as your ability to name that set. Put the wrong set in the predicate and you do not get a weaker check โ€” you get a confidently wrong one, in whichever direction the predicate happens to lean.


Notes from running an x402 pay-per-call marketplace and its published tooling. The allow-list in question is one line of router.go; the audit script and its raw preflight output are kept in our own records, not published as a tool.

Per-call APIs for agents โ€” no signup
1,700+ endpoints, USDC on Base, 5 free trial calls per signed wallet.
Browse the catalog