Core concepts
Security model
Trust boundaries, which routes are authenticated, and what the gateway deliberately does not defend.
Trust boundaries
UNTRUSTED GATEWAY PROCESS TRUSTED
─────────────────── ───────────────────────── ────────────────────────
agent input ────► validates agent input ────► merchant backend
payment proofs may hold a local administrator configured
authorization proofs facilitator gas key
protocol traffic
configuration ◄──── administrator
environment ◄──── operatorEverything from an agent is untrusted and validated. Configuration and environment variables are trusted operator input — which is why only they may select backend URLs.
Which routes are authenticated
The gateway checks the Host header on every request to limit DNS rebinding. Requests with an Origin header must also match server.allowedOrigins.
| Route | Audience | Protection |
|---|---|---|
POST /api/resources/:id/invoke | agents | payment, not authentication |
/mcp, /a2a | agents | payment, not authentication; only when enabled |
GET /api/resources, /health, /.well-known/agent-commerce | anyone | none — public by design |
/.well-known/agent-card.json, /.well-known/acp.json | anyone | none; only when A2A or ACP is enabled |
GET /ready | operators | none, but a fixed vocabulary, never raw errors |
GET /api/receipts, /api/events, /api/payment-attempts, /api/transactions; POST /api/payment-attempts/:id/resolve | operators | server.adminToken, constant-time |
/acp/checkout_sessions… | agents | protocols.acp.auth.token, constant-time |
A disallowed Origin gets 403 before the token check. Otherwise, operator routes return 404 when no server.adminToken is configured, or 401 for a missing or wrong token when one is configured. These checks precede ledger access. The token is accepted only in the Authorization header, never as a query parameter. It does not identify a person; resolutions record a caller-supplied actor. Browser access is governed by server.allowedOrigins, an explicit allowlist that defaults to empty; requests without an Origin receive no CORS headers at all.
ACP Signature and Timestamp verification are not implemented, and a Signature header never replaces the bearer token, so deploy ACP behind TLS. The adapter forwards both headers to the merchant unverified. It never forwards the bearer token, and a configured backend.headers entry wins over a caller header of the same name.
Secret handling
- The default request log records the method, the URL with any query string replaced by
?[REDACTED], the host and the remote address and port, but no request headers. - The logger redacts the
Authorization,PAYMENT-SIGNATUREandAgent-Authorizationheaders and known secret fields such asprivateKey,signerPrivateKey,challengeSecret,adminTokenandtoken, at the top level and one level deep — not deeper. - Receipt redaction is recursive, and the pipeline keeps raw payment and authorization proofs out of receipts.
- A facilitator's rejection reason reaches clients and storage only after trimming and a 128-character
[A-Za-z0-9_.-]check; anything else becomesinvalid_paymentorsettlement_failed. The raw value, with any URL cut to its origin, appears only in logs: at debug level for a rejection, at warn level when the outcome is unknown. - Discovery publishes only each x402 offer's health
status. The reason an offer fails, which can name an RPC host, is logged at debug level. - Environment-substitution errors name the variable and path, never the resolved value. Later validation errors may echo non-secret values such as URLs, so keep secrets in fields meant for credentials.
SSRF and backend calls
- Backend URLs are administrator configuration only. Agent input only fills
{param}placeholders in the path or query, URL-encoded; a placeholder in the scheme, host or port is refused at load. - Empty,
.and..path parameters are rejected asINPUT_INVALID. After substitution, the path must still start with the template's literal prefix. - Input cannot replace a query parameter already fixed in
backend.url; a collision isINPUT_INVALID. - Redirects are not followed; a 3xx is a
BACKEND_ERROR. - The built-in HTTP executor applies a timeout and a 1 MiB response cap to every call. A custom
backendexecutor must enforce its own. - The backend's error body is never relayed to the caller, only its status code; ACP also relays a valid ACP error's type, safe code and param. At most 512 characters of it are logged at
debug.
http://169.254.169.254/…, the gateway will call it. Treat configuration as privileged.Denial of service
| Limit | Value |
|---|---|
| request body | 256 KiB, on parsed routes and raw protocol mounts alike; a raw mount answers 413 when it still can |
| backend response | 1 MiB, plus the configured timeout |
| requests per mounted protocol adapter | 20 concurrent, checked before the body is read — then GATEWAY_BUSY with Retry-After: 1 |
| concurrent MCP tool calls | 8, plus a queue of 64 — then GATEWAY_BUSY |
| receipts and events queries | a bounded list limit |
| Agent-Authorization header | 8192 bytes, checked before decoding |
| unpaid A2A x402-extension tasks | 256 unexpired tasks in the receipt database, each expiring with its requirement or within an hour; a full store fails new purchases with GATEWAY_BUSY and never evicts a waiting task |
| X-Request-Id | logged only when it matches [A-Za-z0-9._:-]{1,64}; the audit request id is always generated by the gateway |
| /ready and /.well-known/agent-commerce | cached for 2 seconds; concurrent requests share a probe, limiting repeated upstream checks |
| signed ERC-8409 quotes | up to 1024 cached per provider, reused while a full payment window remains; each cache miss signs once per EVM offer, so rate-limit unpaid requests at the edge |
| resolve request body | 4 KiB, then 413 |
| A2A message parts and task references | Rejects multiple parts or any task reference by array length (PROTOCOL_UNSUPPORTED), before validating elements |
Multi-network payment checks
A selection from the adversarial matrix for offers on several networks, methods and chain families. A test covers each row.
| Scenario | Result |
|---|---|
| a proof whose accepted echo matches zero or several of the challenge's offers | invalid_payment_requirements; the facilitator is not called |
| a proof for an offer missing from the resource's own x402.accepts list | refused before the facilitator is called |
| an AP2 mandate approved for one offer, presented with a proof for another | the selected destination, network or asset fails mandate binding |
| an EIP-3009 authorization signed for one EVM chain, presented as another chain's offer | invalid_exact_evm_signature: the signature fails against the other chain's EIP-712 domain |
| a Permit2 proof naming another spender, token, amount or witness recipient | invalid_permit2_spender, permit2_token_mismatch, permit2_amount_mismatch or invalid_permit2_recipient_mismatch, before the facilitator is called |
| a Permit2 payment from a buyer without a Permit2 allowance | 412 with permit2_allowance_required in a fresh PAYMENT-REQUIRED |
| the same Solana transaction with other signature bytes, while the first copy is settling | 409 PAYMENT_REPLAYED: the key is the message hash, not the signatures |
| another NEAR delegate under the same sender, key and nonce, while the first is settling | 409 PAYMENT_REPLAYED: the key is the access-key nonce, not the delegate bytes |
| a settlement success naming another network, or no transaction in its family's form | settlement-uncertain, no delivery |
| an EVM token whose decimals differ from assetDecimals | health fails, naming both values |
| x402 options on two networks sharing one RPC endpoint, or mixing testnet and mainnet | CONFIG_INVALID at load |
Buyer protection
Buyer wallets or agent providers must enforce budgets, per-task limits and kill switches. The merchant's gateway handles each purchase separately.
- x402's default
authorizationflow settles only after backend success.upfront, the MPP default, settles first. A later backend failure leaves the buyer charged; the gateway reports settlement and attempts to record an undelivered receipt. See the fail-closed matrix. - Replay checks stop a second settlement with the same authorization; buying loops can use fresh authorizations. See replay.
- A resource can require an experimental AP2 mandate bound to its exact input, price and settlement terms. Open mandates are rejected because their spending limits are not evaluated. See AP2 mandates.
- Operators correlate receipts and events by
requestIdat /api/receipts and/api/events. These records are not dispute-grade evidence.
Payments settle directly to the merchant; the gateway holds no buyer funds and needs no merchant wallet key. Refunds come from the merchant's wallet. Use GET /api/receipts?undelivered=true to find paid purchases whose delivery failed.
An uncertain settlement leaves no receipt even though funds may have moved. Use GET /api/transactions?unresolved=true to find journal entries awaiting a payment or delivery outcome; GET /api/payment-attempts?uncertain=true also covers attempts written before the journal. agent-commerce reconcile checks EVM, Solana and NEAR evidence and resolves attempts with a definite outcome. Retry inconclusive results later or investigate on chain, using the recorded transaction ID when available. To record an operator verdict, use agent-commerce resolve or POST /api/payment-attempts/:id/resolve. doctor reports the count when it is nonzero.
Manual resolution refuses a reserved attempt younger than 15 minutes, and refuses rejected while the recorded expiry is in the future. Without a valid recorded expiry, as on Solana and NEAR, rejecting needs an explicit confirmation that the authorization can no longer land. Neither verdict is checked on chain.
The gateway tries to save the receipt before sending the response. If the connection then drops, a saved receipt for a successful backend call still counts as delivered and is excluded from ?undelivered=true. The gateway does not resend that response and rejects the settled proof on retry. The buyer can make another paid request or ask the merchant for a refund.
What the gateway does not protect against
- It does not secure your backend — authentication and data protection stay yours.
- It does not vet the buyer: no KYC, sanctions screening, fraud scoring or disputes. An AP2 mandate proves a human approved the purchase, not who that human is.
- It does not make payments reversible: no refunds, chargebacks or escrow.
- It does not audit x402, the MCP SDK or their dependencies, which are third-party code.
- It does not provide multi-tenancy, RBAC or policy controls.
- It does not defend against a compromised host.
- Payment and delivery can diverge. Under the
upfrontflow, a backend failure after settlement leaves the payment settled; the gateway records the attempt and an event for reconciliation and issues no refund. Under theauthorizationflow, the x402 default, a backend failure prevents settlement, but a refused or uncertain settlement withholds the response although the backend ran. Useupfront, the MPP default, when backend side effects must follow payment. - It cannot always tell whether a payment settled: a settlement with no verdict is recorded as
settlement-uncertain, with its transaction hash when there is one, and the resource is not delivered. The caller getsPAYMENT_SETTLEMENT_FAILED, which does not mean the funds stayed with the buyer. Operators can list these attempts at /api/payment-attempts and check them with reconcile. Inconclusive evidence calls for a later retry or operator investigation. - A rejected attempt releases its payment authorization: the backend failed before settlement under
authorization, or the provider confirmed that no funds moved. x402 buyers can retry the same authorization; it is verified again, and on-chain replay checks prevent double settlement. MPP buyers need a new challenge because credentials are single-use. Asettlement-uncertainattempt keeps its authorization reserved, even if no funds moved, to prevent replay; any further payment needs a fresh authorization. AP2 mandates are also released on rejection and held on uncertainty. - It does not confirm a remote facilitator's report on chain. The facilitator, with the Solana fee payer or NEAR relayer that pays for broadcasting, is trusted with liveness and with its settlement report, not with the payment terms: the buyer's signature fixes the token, amount and recipient, but the facilitator can delay or drop a broadcast until the authorization expires. A reported success counts once it names the offer's network and a transaction in that family's form.
- Under the
upfrontflow, a rejected request can still have been paid for. Most request-shape checks run before payment, but an unparseable URL and a path that escapes the template's prefix are caught only at the backend call, which follows settlement in that flow. Under theauthorizationflow, a failure at this stage prevents settlement. - It prioritizes delivery over bookkeeping: if persisting a receipt, payment attempt or journal step fails after reservation, the delivery or original error stands. The failure is logged at
error; delivered outcomes list failures known at that point inpersistenceFailures. - After a restart, the journal shows the saved steps and unresolved entries. For
reservedattempts past--min-age,reconcilecan resolve payment from conclusive final chain evidence. Unknown delivery outcomes still need operator review. - Restoring a backup loses newer local records. A proof spent since the snapshot reaches the facilitator again: EVM verification rejects it; Solana and NEAR rely on chain rejection of processed transactions or nonces. Under the
authorizationflow, a replay can reach the backend before settlement fails. A mandate consumed since the snapshot can authorize another purchase until expiry; see Restore.
Report vulnerabilities privately through GitHub's Report a vulnerability, not in a public issue. You get an acknowledgment within 5 working days; the policy is in SECURITY.md. The full adversarial scenario matrix, with the test covering each case, is in docs/security.md.