Skip to content

Security model

tx402 signs payment authorizations on behalf of software that may be running unattended for hours. That makes its security properties load-bearing in a way an ordinary HTTP client’s are not. This page states them plainly, including the limits.

It is buyer-side and non-custodial. tx402 never holds funds and never has custody of a key it did not receive from you. It calls no facilitator endpoint — /verify and /settle are the merchant’s business, not the buyer’s — so the buyer’s attack surface is the merchant’s challenge, the RPC endpoints it reads balances from, and its own configuration.

The threat that shapes the design is the one the target users actually face: an autonomous agent running a long loop, reading untrusted content, and able to make HTTP requests. If that content can induce a payment, the agent’s wallet is only as safe as the guardrails between the request and the signature.

Policy evaluation and the atomic budget reservation both complete before any signer is invoked, on every attempt. A refused request produces zero signatures — the signer is never called, so a hardware wallet never prompts and a KMS never records a use. The test suite asserts a signer call count of zero for every policy, planning, liquidity, and chain-identity refusal.

Each paid attempt produces exactly one authorization with a cryptographically fresh 32-byte nonce, and no authorization is ever transmitted twice. A re-challenge re-plans and re-signs from scratch rather than reusing anything.

Lifetime is min(60 seconds, merchant's stated bound) and can never exceed the merchant’s own. The bound is computed after the message that it constrains exists, so it is true by construction rather than by timing.

Before the external signer is invoked, tx402 re-derives the approved plan and asserts every field of the payload against it — chain, token contract, domain, payer, recipient, amount, validity window, nonce length. On Solana it decodes the serialized transaction rather than inspecting the builder’s own objects, so a construction bug is caught rather than agreed with.

Alongside that, the signer receives a human-readable presentation: domain, asset, atomic and decimal amount, recipient, network, expiry, and request hash. A hardware wallet can show a person what they are approving.

Redirects are never followed after a signature

Section titled “Redirects are never followed after a signature”

No redirect is followed on a paid retry — and by the time one arrives, your signature is already with the merchant. A redirect is a response, so the request that provoked it had to be sent first. What tx402 refuses is the second request, to the redirect target.

That makes both kinds of redirect an exit 8 outcome, with context.paid of "unknown" and the budget reservation held as a non-expiring exposed reservation until an operator reconciles it. Treat them exactly as the error reference tells you to treat exit 8: money may have moved, do not retry blindly, reconcile with the merchant.

Redirect Error Details it carries
Cross-origin TX402_REDIRECT_BLOCKED fromOrigin, toOrigin, causeCategory, reservationExpiresAtEpochMs
Same-origin TX402_PAYMENT_AMBIGUOUS causeCategory (redirect-not-followed), reservationExpiresAtEpochMs

Both throw. You do not get the response back, and the same-origin error does not carry the Location — so a merchant that redirects a paid retry is a merchant you reconcile with, not one you follow.

Same-origin is refused as well as cross-origin, and that is a deliberate v0.1 narrowing rather than an oversight: re-sending a request that already carries a payment signature means tx402 cannot know whether the merchant treats the second URL as the same operation or a new one, so a redirect that looks like a formality could be a second charge. Lifting this would require a merchant-side idempotency signal the protocol does not currently define.

Signatures, private keys, complete signed transactions, and authorization payloads are never written to the event stream, never embedded in an error, and never serialized. toJSON / to_dict on a tx402 error deliberately omits the cause and the traceback, which is where a URL with credentials would otherwise appear.

This is tested rather than asserted: the suite seeds a real secret into every input the request path touches — signing key, bearer token, query credential, body, settlement id — then searches the entire serialised event stream for each one, in hex, base64, and base64url, on the success path and on the refusal path.

The PAYMENT-REQUIRED header is decoded under hard limits: strict base64, ≤64 KiB decoded, JSON depth ≤16, duplicate keys rejected, ≤32 requirements. The resource URL’s origin is validated against the URL you requested, and the challenge is bound to the method tx402 itself issued rather than to one the challenge claims.

Networks, token contracts, decimals, and RPC endpoints come from a release manifest that is Ed25519-signed and verified offline at client construction, against keys compiled into the package. A tampered manifest fails construction rather than routing a payment to an attacker’s contract.

A fleet shares one budget, and its controls resist a drifted worker

Section titled “A fleet shares one budget, and its controls resist a drifted worker”

When many cooperating agents run against one wallet, 0.2.0 gives them a single authoritative budget in a shared durable store, and four controls over it that a data-plane worker cannot relax:

  • A cumulative ceiling as well as an hourly rate. policy.maxTotal bounds lifetime spend against a scope, not just the rolling hour — and a maybe-settled (“exposed”) payment keeps consuming both caps until an operator reconciles it, so an ambiguous outcome cannot quietly free budget to be spent again. See exposed reconciliation.
  • A kill switch. An operator can freeze a merchant scope — or the whole store with "*", on a backend that supports atomic global freeze — and every subsequent reserve is denied before a signer is reached. A freeze stops future authorizations; it cannot revoke a signature already on the wire. See the kill-switch runbook.
  • Recipient pinning. With a pin in place, a merchant that changes its payout address mid-conversation is refused rather than paid. The pin is on the challenge’s payTo, compared canonically, and asserted inside the same atomic reserve as the budget check. See recipient rotation.
  • An admin-state boundary. The controls above are set through an admin credential the agents do not hold; agents hold only a data-plane credential that can reserve and read but cannot freeze, re-pin, or raise a limit. So a worker that has drifted — or been steered by injected content — cannot relax its own guardrails.

What that boundary is, precisely — and what it is not. It protects admin state: a client cannot unfreeze itself, override a pin, or widen a cap. It is not a defense of the spending path against a fully compromised application. An app that has been taken over still holds the signer and can bypass the store to pay directly; bounding that needs signer mediation and is 0.3.0, not this release. The guarantee 0.2.0 makes is for cooperating clients holding data-plane credentials — the fleet shares one budget, spending can be frozen, and no merchant can silently redirect a payment — and this page says exactly that rather than implying more.

Stated plainly, because a security page that only lists strengths is not useful.

  • A compromised process. A key in your process memory is readable by anything that can read your process. tx402 reduces how often it is needed, not what an attacker with code execution can do. Use an external signer. The 0.2.0 admin/data-plane split narrows what a drifted cooperating worker can do to the shared controls, but it does not stop a process that has been fully compromised from spending — it still holds the signer and can bypass the store. Closing that is 0.3.0’s signer-mediation work, and this release does not claim it.
  • A malicious merchant charging what it said it would. tx402 enforces your caps and the protocol’s rules. If you allow $0.10 and the merchant asks for $0.10 for nothing, you pay $0.10 for nothing. Caps bound the damage; they do not judge value.
  • Settlement outcomes. tx402 does not call the facilitator, so it knows what the merchant tells it. AmbiguousPaymentError exists because sometimes the merchant tells it nothing.
  • A hostile RPC endpoint’s data, beyond chain identity. tx402 proves the chain, caps the time, and fails over. It does not independently verify a returned balance.
  • Cross-process budget, by default. The default MemorySpendStore is in-memory and per-client, so two processes have two budgets and the hourly window resets on restart — unless a durable shared store is configured, which is exactly what 0.2.0 adds (Redis, a Cloudflare Durable Object, or a gateway in front of either). With one configured the fleet shares one authoritative budget; without one, this line still holds. See the shared-store runbook.
  • Anything past the 0.2.0 scope: no cross-chain swaps, no asynchronous settlement, no streaming payments, and — stated plainly — no protection of the spending path against a compromised application, which arrives with signer mediation in 0.3.0.

Use GitHub Private Vulnerability Reporting on neogeeks/tx402. Please do not open a public issue for a security problem. The full policy, including scope and what to expect, is in SECURITY.md.