Skip to main content
A buyer’s signing key can move money, and a signed authorization is spendable by whoever holds it until it expires. The SDK, rail402-mcp and rail402 keep both out of everything they print, log, store or return.

Keys

  • Key files are the preferred source. A key file holds one S… seed and, on POSIX systems, must not be readable or writable by group or others; otherwise it is refused with key_file_insecure. rail402 keys new and rail402 keys import write key files with mode 0600 and never overwrite an existing file (key_exists). keys import reads the seed from stdin, without echo on a terminal. No command prints a seed.
  • A seed in the environment is read once. RAIL402_SECRET_KEY is removed from the process environment as soon as the configuration is read, whether or not it is used, and the configuration object holds it as a hidden field that serializes and inspects as [redacted]. RAIL402_MCP_TOKEN is removed the same way.
  • The config file never holds a key. The CLI refuses a config file with a field named like a secret, or any value that is a valid secret seed (cli_config_invalid). It may name a keyFile.
  • In memory, the key is a KeyHolder. The seed is in a private field; the holder exposes only its address and signAuthEntry. Serializing, logging or inspecting it shows the address alone. Any SEP-43 signer (a wallet, a hardware signer) can take its place, and then the SDK never sees a key at all.
The key signs only what the signing guard allows: Soroban authorization preimages for the selected payment, at most one per call; a payment never needs the buyer to sign a transaction envelope. The only transactions signed with the key are the ones rail402 account setup builds for the account itself (trustline and testnet exchange).

Signed payments

  • The PAYMENT-SIGNATURE header (or the MCP _meta["x402/payment"]) is sent only with the paid request to the origin that asked for payment. The unpaid probe carries none, and paid requests never follow redirects.
  • Errors, events and results carry transaction hashes, public addresses, amounts and codes, never signatures, preimages or signed XDR. Every reason and detail string passes a redaction step that replaces any secret seed and any run of 64 or more base64 or hex characters with [redacted], keeping public G…, C… and M… addresses. Seller-supplied rejection text is redacted before it is shortened, so a truncated echo of a signed payload cannot slip through.
  • A seller’s paid answer is untrusted content, and a seller may echo the request it received. Before a result is returned or stored, the call’s own payment is removed from the seller’s text and JSON (HTTP body, MCP content and structured content): the PAYMENT-SIGNATURE value, the payment JSON, its transaction or authorization XDR, and for each signed authorization entry the entry XDR, its signature value and bytes (hex, base64, base64url) and the signed preimage. Each is matched verbatim, JSON-escaped and URL-encoded, and a copy cut off at the end of truncated content is removed as well; every match is replaced with [redacted]. The rest of the seller’s content is returned as sent.
  • The budget ledger stores amounts, recipients, transaction hashes and the sanitized outcome of each call for idempotent replays (with the call’s payment removed from the seller’s content, as above); it never stores payment payloads or signatures. The SQLite file is created with mode 0600 in a 0700 directory.

Output channels

  • rail402-mcp writes logs to stderr only (stdout is the MCP stream). Its tool schemas contain no field for a key, seed, signature, token or authorization; tool results and errors never contain one, even when a seller echoes the payment into its answer.
  • rail402 writes one JSON document to stdout with --json; logs, progress and --verbose events go to stderr.
  • rail402-mcp --http requires a bearer token whenever it can pay, compares it in constant time, and never logs it.

The canary scan

Every pnpm test and pnpm test:integration run drives the payment flows with a fresh canary key and scans everything the SDK, the MCP server and the CLI produced for it (packages/testkit/src/secret-scan.ts, with a self-test that plants leaks and must fail). The needles are the seed (as strkey, hex, base64 and base64url), every signature and preimage the key produced, every signed authorization entry, the payment transaction XDR, the PAYMENT-SIGNATURE value, the MCP payment _meta, and the MCP HTTP bearer token, each also URL-encoded and JSON-escaped, with every 24-character window of the long values searched so a truncated leak is found too. Any valid secret seed found anywhere fails the scan as well. The channels scanned:
  • thrown errors: message, stack, details, the cause chain, JSON.stringify and util.inspect;
  • SDK events;
  • the MCP tools/list schemas, every tool result on success and on every error path, and every JSON-RPC frame and stderr line of the spawned server at trace log level;
  • the CLI’s stdout and stderr for every command, including --verbose and --help;
  • the SQLite ledger file and its WAL and shared-memory files;
  • a decoy origin, used as a redirect target and a second seller, that records every header it receives.
Every suite also pays an HTTP resource and an MCP tool that echo the payment they were paid with into their answer, verbatim, as JSON, JSON-escaped and URL-encoded (paymentEcho in the same file), and scans what the caller receives, idempotent replays and the ledger. The suites are packages/{sdk,mcp,cli}/test/secrets.test.ts (in-process doubles) and secrets.integration.test.ts (a private Stellar network, Rail402 in-process, stock x402 sellers, including refusals, a failed settlement, a redirect after payment and echoing sellers).

In the Playground

The Playground keeps a testnet key in the browser, encrypted under a non-extractable key in IndexedDB, and signs through the same guard. The page never shows or stores the seed in plain form; the only way out is a key file the visitor downloads after a confirmation. Its server holds no key and refuses to start when any environment value is a secret seed. The same canary scan runs over the Playground, with an imported canary key, in the server’s tests (apps/playground/test/secret-scan.test.ts), the engine’s integration test on the private network (apps/playground/web/test/integration/engine.integration.test.ts) and the browser tests in Chromium (apps/playground/e2e/playground.spec.ts). Every payment the page sent is a needle, and a payment may appear only in the request that carried it: The requests exempt from the payment needles (RPC calls and the paid requests) are still scanned, headers and bodies, for the seed and its key bytes. The browser tests also check that the stored wallet key is not extractable, and plant leaks that the scans must find: a payment where it does not belong, and the seed in an RPC request body. The live Playground run repeats the browser scan on the public testnet.