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 withkey_file_insecure.rail402 keys newandrail402 keys importwrite key files with mode0600and never overwrite an existing file (key_exists).keys importreads the seed from stdin, without echo on a terminal. No command prints a seed. - A seed in the environment is read once.
RAIL402_SECRET_KEYis 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_TOKENis 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 akeyFile. - In memory, the key is a
KeyHolder. The seed is in a private field; the holder exposes only its address andsignAuthEntry. 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.
rail402 account setup builds
for the account itself (trustline and testnet exchange).
Signed payments
- The
PAYMENT-SIGNATUREheader (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 publicG…,C…andM…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-SIGNATUREvalue, 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
0600in a0700directory.
Output channels
rail402-mcpwrites 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.rail402writes one JSON document to stdout with--json; logs, progress and--verboseevents go to stderr.rail402-mcp --httprequires a bearer token whenever it can pay, compares it in constant time, and never logs it.
The canary scan
Everypnpm 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
causechain,JSON.stringifyandutil.inspect; - SDK events;
- the MCP
tools/listschemas, every tool result on success and on every error path, and every JSON-RPC frame and stderr line of the spawned server attracelog level; - the CLI’s stdout and stderr for every command, including
--verboseand--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.
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.