SECURITY.md is the policy and the disclosure process. This
page is the engineering detail: what the trust boundaries are, and what we
deliberately do not defend.
UNTRUSTED SEMI-TRUSTED TRUSTED
──────────────────── ────────────────────────── ─────────────────────────
agent input ────► gateway process ────► merchant backend
payment proofs validates all agent input, administrator configured,
authorization proofs holds no keys assumed to be yours
protocol traffic
configuration ◄──── administrator
environment ◄──── operator
Everything from an agent is untrusted and validated. Configuration is trusted input supplied by whoever runs the gateway - which is why backend URLs may only come from configuration.
Never logged, never persisted, never returned:
- private keys, seed phrases, mnemonics
Authorizationheaders and backend API secrets- the
PAYMENT-SIGNATUREheader and raw payment authorisation payloads - the
Agent-Authorizationheader, AP2 presentations, their disclosures, and the merchant checkout JWT they bind signature,secret,apiKey,signerPrivateKey,adminTokenandtokenfields, at the top level and one level deep (see below)
Enforcement is pino redaction on the logger plus explicit exclusion in the receipt store.
The logger's depth limit is real. fast-redact wildcards match a single
level, so REDACT_PATHS covers privateKey and wallet.privateKey but not
a.b.privateKey. Nothing leaks today because every call site funnels caught
errors through describeError, which extracts only {message, name} - but
that is a property of the call sites, not of the redaction config. Do not log a
raw object that may carry a secret at depth ≥ 2. The receipt store's
redaction (src/storage/receipts/redact.ts) has no such limit: it is a
recursive key-pattern strip at every depth. Both have tests. Resolved ${VAR} values are never printed, even
in configuration error messages - errors name the variable, not the value.
A separate trust anchor from payment, and a deliberately small one. The key policy, the two issuer lists and the rotation procedure are in ap2.md.
Every AP2 verification key is a public key an operator wrote into
config.yaml. The gateway performs no key discovery of any kind: no JWKS
endpoint, no jku, no x5u, no issuer metadata fetch, no revocation call.
A JWK is validated member by member at load against an allowlist, so private
material and anything naming a URL is refused without the check having to name
it. That closes an SSRF surface before it exists: no code path lets a presented
mandate cause an outbound request, and removing a key from the config is the
revocation.
The algorithm comes from local policy, never from the JWT header: ES256 over
P-256, one entry, so alg: none and the HMAC family are excluded by
construction rather than by a check that has to remember them. iss and kid
select which configured key verifies a mandate, and an unrecognised pair is
refused - there is no "try every key" fallback that would make kid advisory.
Digests are taken over the bytes that arrived - the compact checkout JWT as presented, and the issuer-signed token - never over a re-serialised object. A normalised payload has a different digest, and hashing it would check a document other than the one being verified.
The gateway makes outbound HTTP calls to URLs it was configured with:
- backend URLs are administrator-controlled configuration only;
- dynamic, agent- or user-controlled backend URLs are forbidden - no code
path constructs a backend URL from request input beyond
{param}substitution into a configured template, with each value URL-encoded. A parameter may only appear once the authority is complete: a template whose{param}reaches the scheme, host or port is refused at config load, because caller input would otherwise choose which host the gateway calls and every path-position defence (containment check,encodeURIComponent) is inert there; - redirects are not followed (
redirect: 'manual'); a 3xx is aBACKEND_ERROR; - every call is bounded by an explicit timeout.
Path parameters are additionally checked for dot segments: . and .. are
rejected as INPUT_INVALID rather than URL-encoded, because
encodeURIComponent does not escape . and the WHATWG URL parser then
normalises .. away - which would let a caller remove path segments and reach
a parent endpoint the operator never exposed. After substitution the constructed
path is asserted to still begin with the template's literal prefix.
Caller input can never override a query parameter the operator baked into
backend.url. A collision is rejected, not silently applied - otherwise an
input key named after an embedded ?apikey=… would replace it.
Not implemented: an IP/CIDR allowlist or a private-address blocklist. If
you configure http://169.254.169.254/…, the gateway will call it. Treat
configuration as privileged.
A backend URL produced by agent-commerce import openapi
is configuration too: it comes from the document's servers or from
--base-url, both supplied by the operator at import time, and it lands in a
file a human reviews before it is merged. A relative or unresolvable server URL
is refused rather than guessed at. The importer makes no network requests of
its own.
On a non-2xx backend response, the gateway states the status code to the
caller (ours to say) but does not forward the backend's response body. A
merchant backend in verbose/dev-error mode routinely emits stack traces,
internal hostnames or SQL fragments - a free, often-unauthenticated resource
call is not a safe place to relay that. The body is truncated (512 chars) and
logged at debug for the operator only; it never reaches a client-visible
field. A merchant that wants pass-through is a per-resource opt-in, post-alpha.
Validated at the boundary, before anything else happens:
| Input | Check |
|---|---|
| resource input | JSON Schema from the resource definition, closed by default at every level: an object schema - root, nested under properties, nested under items, or nested under an additionalProperties subschema - that omits additionalProperties gets additionalProperties: false stamped on recursively at config load, not just at the root. That enumeration was written from the stamper rather than from the validator and drifted three times; it now matches what compileJsonSchema actually recurses into; an operator who sets it explicitly (including explicitly to true) is respected at whichever level they set it. A resource that declares no input: at all gets an empty closed schema, not an always-valid one - declaring nothing means accepting nothing. Unknown properties, including prototype-named keys (__proto__, constructor, …), are matched by own-property lookup only. |
| path parameters | URL-encoded on substitution |
| body size | capped at 256 KB, one number for both surfaces, enforced in two different places: Fastify's bodyLimit runs inside a body parser on the HTTP routes; /mcp deliberately installs a no-op parser so the MCP transport can read the raw stream, so the mount enforces its own byte count instead. A cap that only protects one of two entry points, or two caps that can silently drift apart, is how /mcp ended up with no cap at all in the first place. |
| content type | JSON enforced on the invoke routes. Not on /mcp, where a wildcard no-op parser hands the raw stream to the MCP SDK and the SDK does its own enforcement. |
| payment proof | decoded and schema-validated by the payment provider; a malformed proof is a rejection, never a crash |
| configuration | Zod, strict, before startup |
The reserved _payment field is stripped from tool input before schema
validation, so it can never collide with a resource's own properties.
Covered in detail in payment-flow.md. The invariants:
- Paid resources fail closed - thirteen distinct failure conditions, each with a test, none of which delivers the resource.
verifyhas no fund-moving side effects; onlysettledoes.- Replay is defended twice: on-chain via EIP-3009
authorizationState, and in the gateway via areplayKeyreserved under aUNIQUEconstraint before settlement. The key is derived from the authorisation, not the request, so a replay against a different request still collides. - The gateway holds no buyer or merchant key. The signed authorisation names the merchant as recipient, so a broadcaster cannot redirect funds.
- The effective settlement destination is visible in
/.well-known/agent-commerceand indoctor, so misconfiguration is noticeable rather than silent.
The surface splits by audience, and the split is enforced, not advisory:
| Route | Audience | Protection |
|---|---|---|
POST /api/resources/:id/invoke |
agents | payment, not authentication |
/mcp |
agents | payment, not authentication |
GET /api/resources, /health, /.well-known/agent-commerce |
anyone | none - public by design |
GET /ready |
operators | none, but detail is a fixed vocabulary, never raw errors |
GET /api/receipts, /api/events, /api/events/stream |
operators | server.adminToken, compared in constant time |
GET /.well-known/acp.json |
anyone | none - public by design, and carries no configuration |
/acp/checkout_sessions… |
agents | protocols.acp.auth.token, compared in constant time |
The operator routes carry the merchant's commerce ledger. With no
server.adminToken configured they return 404, not open data - a missing
control must not read as an absent restriction.
Browser access uses server.allowedOrigins, an explicit allowlist defaulting to
empty. Origin reflection was removed: reflecting the caller's own Origin makes
every route cross-origin readable from any page the operator happens to visit,
which is the same thing as having no policy. Agent traffic gets no CORS headers
at all, because it is not browser traffic.
Origin and Host are validated in one place, the gateway's onRequest hook,
so the MCP mount is covered by the same rule as the HTTP routes - including
against DNS rebinding at a hostname resolving to 127.0.0.1.
Every published port in docker-compose.yml binds 127.0.0.1. Port 8545 in
particular is an Anvil node with unlocked accounts and the full anvil_* admin
namespace; on a shared network that would be unauthenticated control of the dev
chain.
ACP is the one agent-facing surface that is authenticated: every checkout
route requires Authorization: Bearer <token>, compared in constant time
against protocols.acp.auth.token. Both sides are hashed before comparison, so
a wrong token costs the same whatever its length. Authentication runs before the
request body is read, so an unauthenticated caller cannot make the adapter
buffer or parse anything.
Discovery at /.well-known/acp.json stays public and carries no configuration:
no bearer token, no backend URL, no mapped resource ids, no idempotency database
path. Only the digest of the token is ever persisted - the idempotency store
scopes keys by SHA-256("acp-auth:" + token), never by the token itself.
Signature and Timestamp verification are not implemented, are not
advertised, and a Signature header is never accepted in place of the bearer
token. Deploy ACP behind TLS: a bearer token on a plaintext connection is a
shared secret in the clear.
payment_data on a completion is buyer payment material belonging to the
merchant's own flow. It passes through to the merchant backend as ordinary
business input and is never logged, never stored in a receipt, and never
converted into an Agent Commerce payment proof. Delegated payment and delegated
authentication are not implemented, so this feature adds no handling of raw card
credentials anywhere in the gateway.
The gateway does not forward the merchant backend's own error body to callers. A backend in verbose or development error mode routinely emits stack traces, internal hostnames and SQL fragments; relaying them would make the gateway a pass-through for someone else's internals to whoever called a free resource. The backend's status code is returned - that is ours to state and useful - and the body is logged server-side at debug level only.
The same rule governs health and readiness detail: a fixed vocabulary on the wire, raw messages to the log; the rule generalises to any value crossing a trust boundary.
Not a focus of this release, but more is in place than this section used to list. What exists:
- request body-size cap, enforced inside the body parsers
- a 1 MB cap on the merchant backend's response -
AbortSignal.timeoutbounds a backend call by time, not by bytes - explicit backend timeouts on every outbound call
- a bounded list limit on every receipts/events query, applied in the store
- a cap on concurrent SSE subscribers
- on
/mcp: a counting semaphore bounding concurrent tool calls (8) plus a bounded queue (64) - over that,GATEWAY_BUSYrather than unbounded growth - readiness memoisation and single-flight, so
/readypolling cannot amplify into one upstream RPC call per request X-Request-Idaccepted only as[A-Za-z0-9._:-]{1,64}, so a caller cannot write an unbounded string into every audit row- an 8192-byte cap on the
Agent-Authorizationheader (MAX_AUTHORIZATION_HEADER_BYTES), checked on the raw header before it is base64url-decoded or parsed as JSON. Over the limit isAUTHORIZATION_INVALID, and nothing from the caller's value is echoed back. The cap is on the encoded header rather than on the decoded proof because base64url expands by 4/3, so a decode-first check would have to allocate the oversized string first. A real Direct Checkout Mandate presentation is well inside it; MCP and A2A carry the same proof in the request body instead and are bounded by the body-size cap
What does not exist: rate limiting, per-agent quotas, adaptive backpressure. Put the gateway behind your own edge if you expose it publicly.
@x402/core, @x402/evm, @modelcontextprotocol/sdk, viem, fastify,
pino and better-sqlite3 are third-party code, pinned exactly. npm audit
runs in the release workflow; high and critical findings are assessed and
documented before a release rather than auto-blocking on irrelevant transitive
advisories.
What a consumer actually installs, audited against the published tarball:
| Install | npm audit |
|---|---|
| the package alone | 0 vulnerabilities |
plus @modelcontextprotocol/sdk, @x402/core, @x402/evm, viem |
0 vulnerabilities |
plus @coinbase/x402 (only for auth.type: cdp) |
2 - 1 high, 1 moderate |
The whole delta is CDP: @coinbase/x402 → @coinbase/cdp-sdk → axios, which
carries a set of high-severity advisories, plus a Solana client tree this
project has no use for. It is an optional peer, imported dynamically only when
that auth type is configured, so nobody else pays for it - and auth.type: bearer covers any facilitator with a static token and installs nothing. This
is stated rather than buried because the affected path is the one handling real
money.
The repository contains Anvil's well-known development accounts, used only on
the local demo chain and labelled LOCAL DEVELOPMENT ONLY - DO NOT FUND. They
are public knowledge and anyone can spend from them. Never send real assets to
those addresses, and never reuse them anywhere else.
Two independent checks refuse them where it would matter: a dev key may not
sign against an RPC that does not look local or private, and a dev address
may not be the settlement destination on any non-local deployment. The second
is the consequential one - a wrong key merely fails to sign, a wrong payTo
succeeds and gives the money away.
eip155:8453 is refused unless the configuration says so in full: an explicit
allowMainnet, a remote facilitator reached over HTTPS, a second explicit
acknowledgement (allowUnauthenticatedFacilitator) if that facilitator takes
no credential, a non-development payTo, and the canonical USDC for the chain
including the EIP-712 domain name it reports. All of it is checked at config
load, so the gateway does not start otherwise and agent-commerce validate
reports it without starting anything.
A facilitator cannot redirect your money - an EIP-3009 authorisation names its recipient, its amount and its chain, so it can broadcast exactly that transfer or nothing. What it can do is see every authorisation you handle, and stop answering. That is why an unauthenticated one is a separate, explicit acknowledgement rather than a warning.
The in-process facilitator is never allowed on a mainnet. To be precise about
why: it is not a custody problem - the facilitator signer never holds buyer or
merchant funds, it pays gas and broadcasts transferWithAuthorization, and the
money moves buyer to merchant directly on-chain. It is a funded key inside the
resource server, so compromising that process means draining the gas wallet
and broadcasting arbitrary transactions from it. With a remote facilitator the
gateway holds no signing key at all.
Every row below has an executed test, not a claim. Nothing here is asserted by reading a log line: settlement outcomes are read back off the chain, and rejection outcomes assert that balances did not move.
| Scenario | Outcome | Where |
|---|---|---|
header tampering (PAYMENT-SIGNATURE mangled, wrong header, absent) |
402, no delivery | tests/unit/gateway, tests/e2e/payment |
| payload tampering (any field of the authorisation) | PAYMENT_INVALID |
tests/e2e/payment, tests/unit/payments-x402 |
| network substitution | wrong_network before settlement |
tests/e2e/payment |
| asset substitution (a real but different token) | wrong_asset before settlement |
tests/e2e/payment |
payTo substitution |
wrong_recipient before settlement |
tests/e2e/payment, testnet suite |
| amount manipulation (below the price) | wrong_amount before settlement |
tests/e2e/payment |
| replay, sequentially | refused, no second transfer | tests/e2e/payment, mainnet suite |
| duplicate concurrent request | settles once, other gets PAYMENT_REPLAYED |
tests/integration/adversarial-payment.test.ts |
| replay after a gateway restart | still refused - the reservation is in SQLite | same |
expired authorisation (validBefore in the past) |
refused before settlement | tests/e2e/payment |
not-yet-valid authorisation (validAfter in the future) |
refused before settlement | tests/e2e/payment |
a {param} in the host position of backend.url |
refused at config load | tests/unit/config/schema.test.ts |
an unknown key nested under an additionalProperties schema |
rejected by the closed schema | same |
| a hostile or unbounded facilitator rejection string | clamped before it reaches buyer, event or ledger | tests/unit/payments-x402 |
| facilitator timeout | PAYMENT_PROVIDER_UNAVAILABLE, settlement treated as uncertain |
tests/unit/payments-x402 |
| facilitator 401 / 5xx | PAYMENT_PROVIDER_UNAVAILABLE, never charged to the buyer |
tests/integration/adversarial-payment.test.ts |
| malformed facilitator response | refused; never read as a verdict | same |
| backend timeout | BACKEND_TIMEOUT |
tests/unit/core/execution |
| backend 500 after payment | receipt records paid-and-undelivered; payer told it settled | tests/unit/gateway, tests/unit/core/execution |
| receipt-store failure | STORAGE_ERROR, never mislabelled PAYMENT_REPLAYED |
tests/unit/storage-receipts |
| a local reader racing the ledger's creation | database and sidecars are 0600 from the moment SQLite opens them | tests/unit/storage-receipts/permissions.test.ts |
| RPC unreachable during verify | PAYMENT_PROVIDER_UNAVAILABLE, not "bad signature" |
tests/unit/payments-x402 |
an external $ref in an imported document |
refused; zero outbound requests | tests/unit/openapi/load.test.ts |
| an imported path value naming another host | percent-encoded into one segment of the configured origin | tests/integration/openapi-import.test.ts |
| an imported query group colliding with a pinned backend query | INPUT_INVALID before payment; nothing settled |
same |
| an imported operation with an unsupported required parameter | never becomes a resource at all | same |
| an ACP request with no, wrong or non-bearer authorisation | 401 before the body is read; zero merchant calls | tests/conformance/acp/protocol.test.ts |
| an ACP request naming an unsupported API version | 400 naming supported_versions; never mapped to the pinned one |
same |
an ACP POST with no or an over-long Idempotency-Key |
400 before the body is read | same |
| an ACP key replayed while the first request is in flight | 409; the merchant is called exactly once | tests/conformance/acp/idempotency.test.ts |
| an ACP key reused with a different body | 422; the merchant is called exactly once | same |
| an ACP completion retried after a 5xx | not cached; the clean retry runs | same |
| a merchant answering an ACP route with a non-ACP document | refused as processing_error; its body never forwarded |
tests/conformance/acp/errors.test.ts |
| a merchant leaking a connection string or stack in an error body | never relayed; the ACP error carries type, code and message only | same |
an ACP Request-Id carrying a header-injection payload |
dropped, never echoed | tests/unit/protocols-acp/adapter.test.ts |
| an ACP checkout resource configured as paid | refused at config load; payment-required at runtime is a 500 |
tests/unit/config/schema.test.ts |
| an AP2 mandate with a tampered signature | AUTHORIZATION_INVALID; nothing settles |
tests/integration/ap2-x402-conformance.test.ts |
| an expired AP2 mandate | AUTHORIZATION_INVALID; nothing settles |
same |
| a mandate from an issuer that is not configured | refused at the trust allowlist, before any signature check | same, tests/unit/authorization-ap2 |
a mandate claiming a trusted kid but signed with another key |
refused at the signature; kid selects the key, never labels it |
same |
a mandate naming a kid the issuer does not have |
refused; no "try every key" fallback | same |
a mandate whose checkout_hash does not match its checkout JWT |
checkout_binding_failed; nothing settles |
same |
| a mandate approved for another resource, input, amount, currency, payment method, network or asset | purchase_mismatch, one coarse reason; nothing settles |
same |
| a mandate silent about the chain the requirement names | refused - fail closed both ways | same |
| a mandate presented twice | AUTHORIZATION_REPLAYED; the second purchase moves no funds |
same, tests/e2e/authorization |
| the same mandate re-presented with a fresh, valid payment proof | still refused; balances unchanged on a real chain | tests/e2e/authorization |
| a mandate replayed under selective disclosure (one mandate, many presentation strings) | refused - the replay key is the issuer-signed token, not the presentation | tests/unit/authorization-ap2 |
| the AP2 replay store unreachable | AUTHORIZATION_PROVIDER_UNAVAILABLE, retryable, never the buyer's fault |
tests/integration/ap2-runtime.test.ts |
| a payment rejected after a mandate verified | the reservation is released; a corrected proof reuses the mandate | tests/integration/ap2-x402-conformance.test.ts |
| a settlement broadcast but never confirmed | the mandate is not handed back; marked uncertain for an operator | same |
| a free resource configured to require a mandate | refused at config load, and again on the execution path | tests/unit/config/ap2.test.ts, tests/unit/core/execution |
an oversized Agent-Authorization header |
AUTHORIZATION_INVALID before any decode; nothing echoed back |
tests/integration/authorization-carrier.test.ts |
Two of those exist because writing them found a bug. The SDK's exact/EVM
scheme reports an unreachable node as invalid_exact_evm_signature, and its
HTTP facilitator client throws a bare Error for a 401 or 5xx - both would
have recorded a failure of ours as the payer's fault, and burned an
authorisation nothing had checked. The provider now treats any throw out of a
facilitator call as "no verdict obtained", because a verdict arrives as a
returned value.
The importer reads a local, operator-supplied document and writes a file for review. It is not on the request path, and after import nothing reads the document again.
- No network, no filesystem walk. There is no remote source option, and
every
$refis checked before the validator sees the document: an external reference (https://…,./types.yaml,file:///…) is refused by name. A document that names one cannot cause a single outbound request. Asserted with a stubbedfetchintests/unit/openapi/load.test.tsand again end to end intests/integration/openapi-import.test.ts. - Bounded work. A source over 10 MiB is refused before parsing. Internal references are resolved lazily with cycle detection and a depth bound, so a recursive schema is a diagnostic rather than an out-of-memory kill.
- No credential is ever imported. An operation declaring OpenAPI security
produces a warning and a review comment pointing at
backend.headers: { Authorization: Bearer ${BACKEND_TOKEN} }; the scheme name, header name and any example value stay out of the generated file. A security scheme never becomes agent-supplied input, andAccept,Content-TypeandAuthorizationheader parameters are ignored per the specification. - The document is data. Descriptions, examples and vendor
x-extensions are serialised into YAML or dropped. Nothing in a document is executed, and nox-extension can alter pricing, payments, the backend URL or protocol exposure - the generator reads only the fields it knows. - Nothing is overwritten. An existing output file stops the run unless
--forceis passed, a failed run writes nothing at all, and the write is a temp sibling plus rename so a crash cannot leave a partial file. - Commerce policy is never inferred. Without
--free/--exposethe generated fragment has nopricingorexposeand will not load, so no operation becomes purchasable or agent-visible without a human deciding so. - Imported resources are not privileged. They are ordinary
CommerceResourceentries: same config validation, same execution pipeline, same pre-payment request-shape checks.tests/integration/openapi-import.test.tsdrives one over HTTP, MCP and A2A and asserts all three produce the identical merchant request.
Buyer identity and screening · fraud and disputes · refunds and chargebacks · multi-tenancy and RBAC · host compromise · supply-chain attestation · side-channel and timing analysis · protocol-level censorship or MEV around settlement · availability guarantees · a malicious facilitator withholding settlement (it cannot redirect funds, but it can decline to broadcast, and fail-closed means the resource is simply not delivered).