Security
In short
- Provider keys are encrypted with AES-256-GCM under a random data key per secret, bound to the org and credential they belong to. They are decrypted in the proxy's memory to forward your requests and are never written to logs.
- Request and response bodies and query strings are not stored. Credential headers and secret query parameters are redacted before a log line is written.
- Your keys and sign-in tokens are stored as SHA-256 hashes. Sign-in is by one-time emailed link; there are no passwords.
- Outbound connections you configure (webhooks, telemetry collectors, custom APIs, single sign-on) are refused when they point at private or internal addresses.
- The hosted service runs in DigitalOcean's Frankfurt (FRA1) region behind TLS with HSTS. Backups are taken nightly, kept 14 days, and live on the same server.
- Found a problem? Email kristiyan@spendbrake.dev. The code is public at github.com/KIvanow/spend-brake.
Spend Brake is operated by Kristiyan Ivanov, an individual. This page describes what the software does to protect your provider keys and your account, as it is built today. It is written from the code, which you can read yourself; where this page and the code disagree, the code is right and I would like to hear about it. It is not a certification: Spend Brake has no SOC 2 or ISO 27001 audit.
Everything below applies equally to a self-hosted install, except that hosting, TLS and backups are then yours to arrange (the self-hosting guide covers each).
Provider keys
How they are encrypted
Each provider key you store is sealed with envelope encryption, in the
encryptSecret function of packages/shared/src/envelope.ts:
- A fresh random 32-byte data key is generated for every secret and used once, with AES-256-GCM and a random 12-byte nonce, to encrypt the key.
- The data key is then wrapped by a master key, either a 32-byte key held in the service's environment or an AWS KMS key. Only the wrapped data key is stored. Rotating the master key re-wraps data keys; the secrets themselves are not touched.
-
Both layers carry associated data naming the purpose, the org id and
the credential id (
purpose=provider-credential). A ciphertext moved to another org or another credential row fails to decrypt. - The data key is zeroed in memory as soon as it has been used, on both the encrypting and the decrypting side.
The same scheme protects webhook URLs and their signing secrets, OpenTelemetry headers, SSO client secrets and billing-export service accounts, each with its own purpose in the associated data.
Where they are decrypted
The encrypted envelope is what the admin API stores in Postgres and writes through to Valkey; the plaintext is in neither. The proxy reads the envelope and decrypts it with the master key in its own memory when it forwards a request, keeping the decrypted key in memory for at most five minutes so it is not unwrapped on every call. The one other place a provider key is decrypted is the worker, which uses a Twilio credential to look up the final price of a message after it is sent. Nothing writes a decrypted key to disk, to a log, to a trace or to a response.
Credentials your application sends are never forwarded to the provider: the proxy drops
Authorization, Cookie, X-Goog-Api-Key,
X-Api-Key, X-Subscription-Token and every
X-Brake-* header from the incoming request and injects the real key itself.
The master key is not part of any backup. A self-hosted install that loses it loses access to every stored credential, which is why the backup script says so each time it runs.
What is stored, and what is not
For each call through the proxy, Spend Brake records a usage event: timestamp, org, key, environment, agent and session labels, provider, route and SKU, worst-case and actual cost, outcome, the provider's HTTP status, latency, the caller's trace context and user agent, request and response sizes, and a one-way hash of the request used to detect identical repeated calls.
Not stored: request bodies, response bodies and query strings. The places, addresses, messages and search terms your requests are about never reach the database. Responses are passed through to your application and not cached. The privacy policy lists every category of data in full.
Logs
The proxy, admin API, notifier and billing services log through a redacting logger
(packages/shared/src/redact.ts). Before a line is written, credential
headers (Authorization, Proxy-Authorization,
Cookie, Set-Cookie, X-Brake-Key,
X-Goog-Api-Key, X-Api-Key, Api-Key,
X-Subscription-Token) are replaced with [redacted], secret
query parameters (key, api_key, apikey,
access_token, token, signature,
client_secret) lose their values, and anything shaped like a Spend Brake
key (sb_live_…, sb_test_…) is removed from free text,
including error messages. Logs are kept for 14 days.
Keys, tokens and sign-in
-
Spend Brake keys (
sb_live_…) are shown once when created. The service stores their SHA-256 hash and looks them up by that hash; the proxy caches the result for five seconds. Keys can be scoped, capped, restricted to an IP allowlist, and revoked at any time. -
Admin API tokens (
sbt_…, 32 random characters) are stored as SHA-256 hashes with an optional expiry, and can be revoked. - Sign-in to the hosted dashboard is by emailed link. The link carries a random 32-byte code in the URL fragment, so it does not appear in server or proxy logs; only the code's hash is stored, it is valid for 15 minutes, and it works once. At most five links an hour are sent to one address, and the response does not reveal whether the address is known. A session lasts seven days; the dashboard keeps its token in your browser's local or session storage and sets no cookies.
- Admin endpoints are rate-limited per client address. Behind the HTTPS front only the private hops in front of the service are trusted to say what that address is.
- Alert actions (Kill, Raise, Mute links in alerts) and outgoing webhooks are signed with HMAC-SHA256: action links with a server secret, webhooks with the per-webhook secret you are shown when you create it, over a timestamp and the raw body.
- Audit log. Changes to caps, keys, credentials, team members and kill switches are recorded with who made them and when.
Outbound connections
Several features make the service connect to a URL you supply: alert webhooks,
OpenTelemetry collectors, custom APIs you add to the proxy, and (with the Business or
Enterprise license) the OpenID Connect issuer for single sign-on. Each of these goes
through a guarded HTTP client (packages/shared/src/egress.ts) that refuses
loopback, private, link-local (including cloud metadata), carrier-grade NAT, multicast,
unspecified, NAT64 and IPv4-mapped addresses. The check runs on the address actually
resolved and connected to, not just on the hostname, so a name that resolves to an
internal address is refused too, and IP-literal hosts are refused before any connection
is made. The SSO client does not follow redirects. When a custom API is refused, the
caller gets an error and nothing is sent.
On a self-hosted install, where a collector or an API may legitimately live on the same
network, each guard can be relaxed on purpose with
ALLOW_PRIVATE_WEBHOOKS, ALLOW_PRIVATE_COLLECTORS,
ALLOW_PRIVATE_CUSTOM_APIS or ALLOW_PRIVATE_IDP. All are off by
default.
Transport and browser protections
-
The hosted proxy and dashboard sit behind Caddy, which obtains and renews certificates
from Let's Encrypt. Both send
Strict-Transport-Security: max-age=31536000; includeSubDomains, so a browser that has visited once will not go back to plain HTTP. Server version headers are removed. -
The dashboard is served with a Content Security Policy that allows scripts and styles
from itself only, connections to its own API and to PostHog EU, and forbids framing.
This website has a stricter policy still (
default-src 'none', one pinned analytics script with a subresource integrity hash) and sendsX-Content-Type-Options,X-Frame-OptionsandReferrer-Policyheaders. - Postgres and Valkey are not published outside the service's container network; the application ports bind to the loopback interface and are reached only through Caddy.
Hosting and backups
The hosted service, its database and its cache run on DigitalOcean in the Frankfurt, Germany (FRA1) region. This website is served by Vercel. Transactional email goes through Resend, payments through Stripe, and product analytics to PostHog's EU cloud; the privacy policy says what each of them sees.
Backups of Postgres (configuration, ledger, audit log) and Valkey (live counters, reservations, kill switches) are taken nightly and kept for 14 days. They are stored on the same server as the service: they cover a bad upgrade or a mistaken deletion, not the loss of the machine. They contain the encrypted credential envelopes and not the master key, so a copy of a backup on its own does not expose provider keys. Deleted data may remain in backups until they rotate out.
What a key cannot protect you from
Caps hold only if the proxy is the only path to spend: a provider key that is also used
directly is uncapped there. brake init prints the steps to restrict a key
to the proxy's outbound addresses and the APIs you use. Overshoot is bounded by a single
call's worst case unless a key opts into fail-open, and while Valkey is unreachable the
proxy fails closed with 503 for every other key. Keep the provider's own
budgets and quotas on as a backstop.
Reporting a vulnerability
If you believe you have found a security problem in Spend Brake, the hosted service or this website, please email kristiyan@spendbrake.dev with enough detail to reproduce it. I read that address myself. Please give me a reasonable window to fix the issue before you publish it, and do not access, change or delete other people's data while demonstrating it. There is no bug bounty.
The whole codebase, including the proxy, the budget engine, the encryption and the infrastructure files, is public under Apache-2.0 at github.com/KIvanow/spend-brake. If you would rather not rely on my hosting, run it yourself and read what it does.