๐Ÿ“… 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

Three Hand-Maintained CORS Lists, Four Misses

2026-10-18

A browser-based agent makes a paid call. The server logs nothing. Not a 402, not a 400, not a timeout โ€” nothing, because the request never left the browser. The preflight failed on a header name that was missing from a list.

We have hit this shape four times in our own gateway. The fourth time was today, and it is the reason this post exists.

What the three lists gate

CORS is enforced by the browser, not the server. Three response headers answer three different questions, and each one is a list a human maintains by hand:

HeaderQuestion it answersIf an entry is missing
Access-Control-Allow-HeadersWhich request headers may the browser send?Preflight fails; the call is never sent
Access-Control-Expose-HeadersWhich response headers may JavaScript read?The call succeeds but the client cannot see the price, receipt, or refund state
Access-Control-Allow-MethodsWhich HTTP methods may the browser use?PUT/PATCH/DELETE fail at preflight

None of these failures produce a server-side error. That is the whole problem: the server sees a well-formed request or no request at all, so every server-side health check stays green.

Why agent APIs walk into it

x402 and MCP put the interesting parts of the protocol in headers. A price quote is PAYMENT-REQUIRED. A receipt is PAYMENT-RESPONSE. Trial identity is a signature plus a timestamp plus an agent id. An async job is a token. Every one of those is a header that has to be named in a list โ€” and a protocol that is still moving will keep adding them.

Server-to-server agents never notice. A browser agent notices immediately, and the only person who sees the error is the one whose console says Request header field X is not allowed by Access-Control-Allow-Headers.

Our four misses, with dates

Each one was fixed by adding a word to a list. That is not the interesting part. The interesting part is that the same class recurred after each fix, because the list is not connected to the code that generates the headers.

A census, not a resolution

The durable fix is to stop trusting the list and start deriving the population from the source that emits or reads the headers. On our gateway that means three scans:

# 1. response headers the code writes
grep -rhoE 'c\.Header\("[A-Za-z0-9-]+"' internal/httpapi/

# 2. request headers the code reads (literals, plus const names resolved)
grep -rnE '(c\.GetHeader|Header\.Get)\(' internal/

# 3. registered HTTP methods
grep -rhoE '\.(GET|POST|PUT|PATCH|DELETE)\(' internal/httpapi/router.go

That population is easy to get wrong in exactly the way the lists are. Our first version matched only direct c.Header("โ€ฆ") calls. Three trial headers โ€” X-Trial-Remaining, X-Trial-Max, X-Trial-Almost-Exhausted โ€” are emitted from a helper that returns a map[string]string, applied in a loop. All three were documented in our docs and invisible to the check, which printed clean. The census now also reads map-literal keys and indexed assignments, and it prints every c.Header(variable) site so the dynamic edge is visible instead of assumed away.

Then classify every client-facing name into exactly one bucket: listed, same-origin only (with the reason it needs no list entry), or not a client signal. Anything unclassified fails the check. Names that are intentionally missing go into a fourth bucket, known open, and that bucket has a reverse assertion: the day the name appears in the live list, the check fails until the entry is deleted. Otherwise the table starts lying in the other direction.

A control worth keeping: run the judge against fixtures, not just production. Our fixture set includes โ€œa known-open entry that is now allowed must failโ€ and โ€œa header that was in the live list and disappeared must failโ€. Without the reverse cases a census only reports the past.

Verify the source you read is the binary that serves

Reading the source is half the job. A compiled binary can lag the source: someone edits the middleware, nobody rebuilds, and every static check still passes. One preflight answers it:

curl -s -D - -o /dev/null -X OPTIONS https://minia2a.uk/x402/gas \
  -H 'Origin: https://minia2a.uk' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: X-Agent-ID'

The response is a 204 with three headers. Compare those three lists to the ones in your source. If they differ, the binary is older than the tree you are reading. For us, the same probe also became the acceptance test for each fix: send the header you believe is allowed, read the echoed allow-list, and stop guessing.

The general rule

A hand-maintained list is a promise the runtime cannot check for you. Every header an agent sends or reads is part of the API surface, and every list entry is a place where the surface and the promise can drift silently. Derive the population from the code, classify every member, and make the table fail when a known gap closes. The failures you never see on the server are the ones worth a mechanical check.


The checks described here run against our deployed gateway source and a first-party preflight. Header names and route paths are from our production configuration. No customer data is involved.