Skip to content
· 8 min LIVE

Your API Process Should Not Hold the Money Key

One env variable ran money, verdicts and ops authority at once — so we split it into three keys and moved the money key out of the API process behind a Unix-socket signer service. Fail-fast 503 before x402 charging, policy-bounded signatures, pre-announced {current,next} rotation — plus the honest list of what it does NOT protect against.

AB
AgentBadge Team
Agency for the Agentic Web
Your API Process Should Not Hold the Money Key

We removed the settlement private key from our API process entirely: authority split into ARC_SETTLE_KEY (money) / ARC_OPS_KEY (ops) / ARC_VERDICT_SIGNER_KEY (verdicts), the money key lives in a second process reachable only over a Unix socket, paid routes fail 503 before x402 payment when the signer is down, and rotation is pre-announced via a public {current, next} signer list. Env scans of the API process find no money key — verified in CI.

What does a "god key" actually buy an attacker?

Short answer: everything your platform is trusted for. A leaked private key inherits every authority you granted the address — payments it can settle, attestations it can forge, reputations it can fake. Splitting keys by authority means a leak costs one power, not all of them.

Our ARC_EVALUATOR_KEY was three keys wearing one trench coat:

  • Money — evaluator authority on escrow settlements. This one drains real funds.
  • Ops — attestations, anchor writes, venue evaluation calls.
  • Verdict — the cryptographic signature behind every public verdict we publish.

A RCE that dumps /proc/<pid>/environ gets all three in one read. The wallet funding gas and the identity buyers verify against were the same secret. That's not a credential, that's the crown jewels in a .env file.

Three bounded keys replace one god-key: money, ops, verdict

How do you split key authority without breaking payments?

Short answer: give each power its own secret, and never let the money key live in the same process that serves HTTP. Three env secrets replace one — and the dangerous one never enters the API process at all.

  • ARC_SETTLE_KEY — money. Only allowed to settle escrow contracts.
  • ARC_OPS_KEY — operational writes: attestations, anchors, venue-eval.
  • ARC_VERDICT_SIGNER_KEY — verdict signatures (the trust surface).

The legacy combined key ran one release as a deprecated fallback that logs a warning, then got removed from deploy secrets entirely. Old secrets don't linger — that's the entire point.

Trust boundary: API env holds ops + verdict keys, signer env holds the settle key behind policy

How does a signer service on a Unix socket work?

Short answer: the key lives in a second process on the same host. The API talks to it through a local socket file — NDJSON-RPC, no TCP, zero network surface — and asks it to sign. Env dumps of the API process come back empty.

The signer is a small Bun service (src/signer-service) that owns the money key and answers signMessage, signTypedData, sendTransaction, health, getAddress. The API holds a SocketSigner client behind an ArcSigner seam — the same interface an env key implements, so callers can't tell which backend is wired.

# supervisor launches API with the money key REMOVED from its env:
exec env -u ARC_SETTLE_KEY -u SIGNER_KEY bun src/server/index.ts

And the signer doesn't sign just anything it hears — a policy layer allowlists target contracts (SIGNER_ALLOW_TO) and caps gas (SIGNER_MAX_GAS). Even a compromised API can only request signatures the operator pre-approved.

API process and signer service connected only by a Unix socket

What happens when the signer service dies?

Short answer: paid routes fail fast — HTTP 503 before payment verification or settlement runs, so a client can never be charged for a service that can't sign. Free routes stay up. The operator gets an alert. No silent fallback key exists by design.

A health gate pings the signer before the x402 payment middleware. Dead service → 503 signer_unavailable and the payment path never executes — charged stays zero in the test. An alert fans out to the operator's Discord webhook and Telegram, throttled to one per five-minute cooldown.

The uncomfortable alternative — keeping a fallback key in the API env "just in case" — recreates the god-key the whole exercise removes. We'd rather refuse money than risk it.

Sequence: paid request hits the health gate before x402; dead signer → 503 + operator alert, alive → socket sign + settle

How do you rotate a signer without breaking old verdicts?

Short answer: publish {current, next} signer addresses ahead of time, and make every artifact self-describing — a verdict embeds its signer's address, so verification never depends on which key is "current". Stolen key? Rotate current; consumers already trust next.

GET /keys/verdict-signers.json serves a two-slot list borrowed from electron-builder's auto-update trust model. Every verdict is EIP-712 signed and carries its evaluator address — ecrecover the signature and compare, no key registry needed. Verdicts from the retired key verify forever, which is exactly what a verification endpoint must do after an emergency rotation.

What does the signer audit trail look like?

Short answer: every request becomes one NDJSON line — operation, target, value, result, txHash or denial reason. If the API starts asking for things outside the allowlist, the log shows the attempt and the refusal.

{"op":"sendTransaction","to":"0x8004…","value":"0","result":"ok","txHash":"0x…"}
{"op":"sendTransaction","to":"0xdead…","result":"denied","reason":"to not in SIGNER_ALLOW_TO"}

The second line is the interesting one: that's the policy layer catching a call the operator never approved — proof the socket boundary has teeth.

Terminal showing signer audit log: green ok line and red denied line

Prove it — run the env scan yourself

Short answer: on Linux, read the live environment of any process: tr '\0' '\n' < /proc/<pid>/environ | grep -iE 'settle|signer' should return nothing for the money key. The API process physically cannot leak what it never held.

On our deployment it's fly ssh console → the same grep → empty. ARC_OPS_KEY and ARC_VERDICT_SIGNER_KEY may still appear — those stay in env today, which is the honest part of the next section.

What does this not protect against?

Short answer: an attacker on the same host can still ask the signer to sign — the boundary protects the key from the API process, not from the machine. Policy limits damage; it doesn't eliminate it. And two of the three keys still sit in the API env until we migrate them.

  • Host-level RCE still hurts. Same-machine attacker reaches the socket. The allowlist + gas cap bound the blast radius — bounded, not eliminated.
  • Ops and verdict keys are still in env. Money moved first because it was the most expensive to lose. The seam (ArcSigner) already accepts remote backends; ops-key migration is next.
  • One more process to babysit. Cost: ~$0 recurring, plus a supervisor restart loop and a 503-when-dead contract.
  • Straggler drain. Jobs submitted pre-rotation settle via a dedicated script that reads the old key from an operator file, never from server env. Until the drain completes, the old key still exists — in a secrets file, not a process.

When should you use KMS or MPC instead?

Short answer: when the transaction volume makes self-hosting the risky option, or compliance requires non-exportable keys. AWS KMS, Web3Signer, or Circle developer-controlled wallets — the ArcSigner seam already accepts all three without changing callers.

We verified Circle's developer-controlled wallets support ARC/ARC-TESTNET including EIP-712 typed-data signing: keys live at Circle, we hold only a 32-byte entity secret encrypted per request. Cheaper than a full KMS path — the tradeoff is vendor lock-in on the paid path, so v1 stays self-hosted and Circle remains the designated ops-key candidate when volumes justify it.

For AI Agents

  • ArcSigner seam: address, signMessage, signTypedData, sendTransaction, ping. Backends: createEnvKeySigner, createSocketSigner — @agentbadge/circle-payments on npm.
  • Signer trust list: GET https://agentbadge.xyz/keys/verdict-signers.json → {current, next} evaluator EOAs. Prefetch next before you need it.
  • Verify any verdict: GET https://agentbadge.xyz/api/verify/:verdictId — recovered signer vs artifact's evaluator address. Works for retired keys by design.
  • 503 contract: signer_unavailable means paid capacity is down; payment was not attempted. Retry after operator restart — no idempotency risk, nothing was charged.

The one-sentence version

Split your keys by authority, keep the money one out of the HTTP-serving process, refuse payment when the signer is down, and publish your next key before the current one leaks.

Don't trust the env file. Check it.