Authentication
Two mechanisms, doing two different jobs:
A token alone is not enough for money movement. If a token leaks, it can be used to send whatever the attacker likes; a signature binds the request to its body, path and moment in time.
Tokens
Scoped and expiring. A client can never receive a scope it was not granted — asking foradmin when you hold quotes:read is an error, not a silently narrowed token. Revoking a client invalidates its live tokens immediately rather than waiting for expiry.
Signatures
The canonical string isMETHOD\npath\ntimestamp\nsha256(body). Verification checks, in order:
- Timestamp drift — outside ±5 minutes is refused before anything else, so an old request cannot consume replay-cache space.
- Signature match — constant-time comparison, so a wrong signature leaks no timing information about how wrong.
- Replay — a signature already used is refused.
Gateway
Idempotency
A partner retrying a payout must get the original response, not a second payout.
The conflict case matters most. Silently returning the first response would hide a client bug behind a success, and the client would never learn that its second, different request never happened.
Rate limiting
Token bucket per tenant: a sustained refill rate plus a burst allowance. Denials carryretryAfterMs, rounded up so a client never retries a millisecond too early.
Webhooks
Delivery is at-least-once with signed payloads.verifySignature is exported for receivers to use — partners verify with exactly the function that signs, rather than reimplementing it from prose and getting the concatenation order subtly wrong.
Receiver-side verification enforces a timestamp window, so a captured payload cannot be replayed forever, and compares in constant time.
Secrets
Envelope encryption. Each secret gets its own AES-256-GCM data key; the data key is encrypted under a master key. Two consequences:- Rotating the master key rewraps data keys rather than re-encrypting every secret.
- A leaked data key exposes exactly one secret, not the whole store.
Redaction
Applied by key name (client_secret, authorization, api_key, private_key, iban, pan, …) and by value pattern (IBANs, whsec_…, Bearer …, card-length digit runs).
Both are needed: a token is caught whether it sits under a known key or is embedded in free text like "header was Bearer abc…". Every span attribute and log field passes through it, so redaction is a property of the logging path rather than a discipline each caller must remember.
Observability
A corridor transfer crosses five contexts. One trace id threading all of them is the difference between “the transfer failed” and “the payout rail timed out after the chain reached finality”.inSpan ends the span correctly on both paths and records an exception event with an errored status before rethrowing — a failed span is never left open.
Metrics are RED — rate, errors, duration — with labelled counters and percentile histograms.
What the mutation tests proved
Security code that passes its tests while being broken is worse than no tests. Four deliberate defects:What is not here yet
- Real transport.
WebhookTransportis an interface; nothing makes an HTTP call. - Persistence. Tokens, idempotency records, deliveries and secrets are in-memory. The Prisma
idempotency_keytable exists from Phase 0 but is not yet wired. - A real KMS. The master key is a process-local buffer. Real deployments use an HSM or cloud KMS; the envelope structure is what would carry over.
- mTLS. Mentioned in the plan, not built.
- Distributed rate limiting. The token bucket is per-process; multiple instances would need Redis.