CostCompass An Almanac Beta

Security at CostCompass

CostCompass holds something dangerous on your behalf: API keys and OAuth credentials for the AI and compute providers you use. A single leaked Anthropic key can run a five-figure bill before you notice. We take that responsibility seriously, and this page describes how — including the places we made trade-offs.

This is not a marketing page. It is written for engineers and security-minded readers who want to know whether the design holds up. If a section ends with "and that's why this is safe," we owe you the reasoning, not the assertion.

TL;DR

  • Your provider API keys are encrypted in your browser with your password. Our server stores an opaque ciphertext blob and cannot decrypt it. We do not have your password and have no mechanism to recover it.
  • Outbound calls to provider APIs go through two small, open-source Rust services collectively called the broker tier. The forwarding broker carries every per-fetch HTTPS call; the oauth-broker carries the OAuth handshake for confidential-client providers like Google. Both enforce a hardcoded hostname allowlist and run one forked process per request. Our main App Server never holds a decrypted provider credential — even the Google OAuth client_secret lives only in the oauth-broker.
  • Every response the forwarding broker returns is HMAC-signed. Our App Server refuses to ingest usage data that is not signed. Your browser is treated as an untrusted relay.
  • And the signing now runs in both directions. For the requests our App Server fully authors — today, the BigQuery query that reads your Google billing data — the App Server signs the request and the broker verifies it before forwarding. A tampered-with request is rejected, not forwarded. So a compromised browser cannot quietly swap in a different query than the one we built.
  • We made three honest compromises, called out in detail below: (1) confidential-client OAuth (Google and GitHub App mode) requires a server-side client_secret to mint access tokens — we have isolated that secret in a single-purpose Rust process, but it cannot be eliminated, (2) we reuse the PBKDF2 salt across re-encrypts of the same vault (safe, but a deviation from the RFC), and (3) if you forget your vault password, your data is unrecoverable. That is a deliberate consequence of not holding a recovery key.

Threat model

What we defend against

  • A compromise of our App Server. An attacker who pops our backend gets the encrypted vault blob, not your keys. They still need your password.
  • A compromise of our database. Same property — the vault column is a JWE ciphertext.
  • A passive network observer. All traffic is HTTPS. The vault password never leaves your browser.
  • A stolen session cookie. Grants application access (read your usage data, change settings), not vault access. The vault password is required to decrypt and is never sent to a server.
  • A malicious or compromised browser tab on a different origin. Cross-origin scripts cannot read our cookies or call our worker.
  • A compromised provider returning a forged response to the browser. The broker signs the response over an authenticated body; the App Server verifies before ingest. The browser cannot fabricate or replay usage data.

What we do not defend against

  • Malware, malicious browser extensions, or a compromised browser binary on your device. They run inside the same process boundary as our code and can observe whatever the page can.
  • Physical access to your computer while the vault is unlocked.
  • You forgetting your vault password without an export on hand. There is no recovery path; see below.
  • A targeted attacker with live memory-read access on our broker host during one of your active refreshes. The design bounds the blast radius to one key at one moment; it does not eliminate it.
  • Misuse by a provider that you have authorized via OAuth. Once Google, GitHub, or another OAuth identity provider holds an access token for your account, what they do with it is between you and them.

The trust boundaries

The system has four distinct trust zones, and each one knows a different subset of your secrets.

Trust boundaries between browser, App Server, broker, and providerTrust boundaries between browser, App Server, broker, and provider

Your browser holds the password (in RAM only) and the derived encryption key (inside a Web Worker, as a non-extractable WebCrypto object that JavaScript cannot read out). It composes outbound HTTP requests with your provider key in the Authorization header.

The App Server stores the encrypted vault, your account, and your usage history. It never sees your password or a decrypted key. It does see provider response bodies after the broker validates them, because that is the data we are aggregating for you.

The forwarding broker's listener validates your session, parses the request, enforces rate limits, and forks a worker. It does parse the Authorization header — but only into a secrecy-wrapped value whose cleartext it never extracts. The plaintext key is exposed only inside the forked worker, whose entire address space the OS reclaims when it exits.

The forwarding broker's forked worker reads the header, validates the destination hostname against a hardcoded allowlist, verifies the App Server's signature on any request we mark as server-authored (rejecting it before the call if the signature is missing or wrong), makes the HTTPS call, signs the response with HMAC-SHA256, returns it, and exits. The OS reclaims its memory. There is no connection pool and no persistent state.

The oauth-broker is a sibling service that handles the OAuth handshake for confidential-client providers (Google and GitHub App mode). It holds the OAuth client_id/client_secret (and, for GitHub App mode, the App's RS256 private key) and runs the per-fetch token-mint exchange in a forked worker with the same fork-per-request, exit-on-completion shape as the forwarding broker. The App Server has no OAuth code paths and never holds a provider OAuth credential. See §1 below for what this means and what it does not solve.

How the vault works

Your vault is a JWE blob — a standard format defined by RFC 7516. The math is unsurprising; the property we care about is that we can store it on our server and still not be able to read it.

Vault unlock and decryption flow inside the browserVault unlock and decryption flow inside the browser

The cryptography in one breath: your password is run through PBKDF2-SHA256 at 600,000 iterations (the OWASP 2024 baseline) to derive a key-encryption key. That KEK unwraps a fresh 256-bit AES-256-GCM content-encryption key, which decrypts the vault JSON. A new content-encryption key and a new 12-byte nonce are generated every time we encrypt — so even though we hold the password in RAM across multiple saves, we never reuse a GCM nonce.

Two structural properties matter more than the parameters:

The decrypted key is non-extractable. When the Web Worker imports the unwrapped CEK into the WebCrypto API, it does so with extractable: false. This means the raw bytes never become available to JavaScript — not to our code, not to a malicious extension, not to an XSS payload. Code can ask the WebCrypto subsystem to use the key, but cannot read it out.

The vault is a separate JS context. All decryption happens in a Web Worker, not the main thread. The main thread receives entry metadata (provider, label, instance key) but never the api_key field by default. A plaintext provider key reaches the main thread only at moments you trigger: the brief construction of an HTTP Authorization header for an outbound refresh, and the two explicit, user-initiated actions of revealing a key (the eye toggle in the edit form) or exporting your vault. Neither is a background flow — each requires a deliberate action, and the export is one-shot, confirm-gated, with its buffer zeroed once the file is built.

"Remember me" caveat. If you opt in, the unwrapped CEK is cached in IndexedDB as a non-extractable WebCrypto handle for up to 14 days. The raw bytes still cannot be read out, but anyone with access to your unlocked browser profile can use the cached key. Treat that checkbox the same way you would "remember this device" on any other site.

How the broker works

The broker is a small Rust service whose job is to forward HTTPS requests to a fixed set of provider hostnames and sign the responses. It is the only component that ever holds a plaintext provider key, and it holds them for tens of milliseconds inside short-lived child processes.

Broker forwarding flow with HMAC-signed responseBroker forwarding flow with HMAC-signed response

A few specifics worth knowing:

The hostname allowlist is in source. It lives in one Rust file. It is an exact, case-insensitive match — no wildcards, no subdomain matching, no per-deployment overrides. Adding a new provider hostname requires a code change and a redeploy. evil.api.anthropic.com.attacker.com does not match api.anthropic.com, and there are tests for that.

The listener never exposes the key. The listener does parse the request — including the Authorization header, which it materializes into a secrecy typed wrapper — but it never converts that wrapper to cleartext. The plaintext key (.expose()) is materialized only inside the per-request forked worker, whose whole address space the OS reclaims on exit. The guarantee is per-request process isolation, not that the bytes are never parsed.

Logs are structurally key-free. The Rust secrecy crate wraps sensitive types and does not implement Display or Debug. Logging a secret is a compile error, not a code-review heuristic. Tests assert that test keys do not appear in log output.

Secrets are zeroed on drop. Key material is held in secrecy::SecretString and similar wrappers that overwrite their backing memory when they go out of scope (via the zeroize crate's volatile writes, which the compiler is not allowed to optimize away). This is belt-and-suspenders, not the primary guarantee — userspace zeroing cannot reach register spills, stack copies made by the compiler, or pages that have been swapped to disk, nor the copies the worker deliberately makes when it hands the key to the HTTP client (those exposed bytes are not zeroized). The structural guarantee is the forked worker exiting, after which the OS reclaims the entire address space. Zeroing reduces the window during the worker's lifetime; process death closes it.

Responses are signed. Every response the broker returns carries an HMAC-SHA256 signature over a canonical string including the upstream status, the body, and a signing token minted by the App Server. The token is bound to one fetch entry's run — a signed response from one provider/entry cannot be replayed against another, even within the same refresh. The App Server verifies signatures on ingest. If a browser-side attacker tries to inject forged usage data — to inflate or hide spend — the signature fails and the data is rejected. The HMAC key is shared between the broker and the App Server, never the browser.

Requests are signed too — the server's intent is pinned. Signing also runs in the other direction. When the App Server fully authors a request — the clearest case is the BigQuery query that reads your Google billing data, where the App Server itself writes the SQL — it signs that exact request with the same shared key (under a distinct prefix, so a request signature and a response signature can never be confused for one another). The broker re-derives the signature inside the same forked worker and, for the small set of destinations we mark as requiring it (BigQuery today), refuses to forward a request whose body arrives without a valid signature. This is a no-downgrade rule: stripping the signature off a protected request gets it rejected, not waved through. The effect is that a compromised browser cannot substitute a different query — say, one that reads more than you authorized — for the one we built. This covers requests the server fully determines; requests whose details are only known at runtime (the follow-up calls that poll for a query's results, keyed on an ID the server cannot predict) and requests other providers shape themselves are still bounded by the hostname allowlist and the response signature, as before.

Where we made compromises

This section is the reason this document exists. No system is purely "zero-knowledge" once you connect it to OAuth-based providers and a real user experience. We would rather you read the compromises here than discover them later.

1. Confidential-client OAuth still requires a server-side secret (now isolated in the oauth-broker)

Google Cloud billing data is reached via Google's OAuth flow. OAuth's design for confidential clients requires the application to hold a client_secret — a per-application credential (not a per-user one) that Google's token endpoint demands in order to turn a refresh_token into a usable access_token. We cannot put that secret in the browser; anyone could read it out and mint tokens on behalf of every CostCompass user. So it lives server-side. That much is fixed by Google's protocol.

What we control is which server-side process holds it, what else that process can reach, and how long the secret material is in any one place. As of the encryption-refactor work that introduced the oauth-broker, the answers are:

  • The App Server holds no OAuth credentials and runs no OAuth code paths. No client_id, no client_secret, no /authorize endpoint, no /callback endpoint, no token-mint endpoint. A directory grep of the App Server source tree for Google OAuth identifiers comes up empty.
  • The OAuth client_id and client_secret live in the oauth-broker — a separate Rust process, sibling to the forwarding broker, with the same isolation posture: distroless image, non-root user, dropped Linux capabilities, no swap, read-only filesystem, and reachable only on a docker network peered with the front-door reverse proxy.
  • The per-hour token mint runs in a forked oauth-broker child. The child reads the user's refresh_token from the request body, combines it with the client_secret, calls Google's token endpoint, returns a fresh access_token, and exits. The OS reclaims its memory. The mint endpoint is stateless: no access tokens persisted, no refresh tokens logged, no connection pooling across requests.
  • The data fetch path is identical to every other provider. The browser sends the minted access_token to the forwarding broker, the forwarding broker calls BigQuery, the signed response returns to the browser, and the App Server verifies the HMAC on ingest. The App Server never sees the access token; it sees only the broker-signed response body.

Why this is a compromise even with the oauth-broker in place. The structural property we have for Anthropic, OpenAI, and other paste-in providers is no server-side credential material exists that could pull this user's data without the user. For confidential-client OAuth, that property does not hold: a sufficiently determined attacker who pops the oauth-broker, and obtains a user's vault-stored refresh_token through a separate browser-side compromise, and catches the brief window during which the forked child holds both, can mint access tokens. The forwarding broker faces the same shape of attack for paste-in keys: it briefly holds plaintext keys during forwarding. The two brokers are symmetric in this regard — process-per-request, exit-on-completion, no shared state. The compromise is what's possible in principle, not anything you'd routinely observe.

Paths we evaluated and rejected.

  • PKCE on a public Web client. Google's "Web application" client type still requires client_secret in the token exchange in 2026; PKCE is additive, not a replacement. The token endpoint also does not serve CORS, so the exchange cannot happen directly in the browser. Switching to a Desktop client type from a hosted SaaS origin is policy-disfavored and reviewer-rejected. Dead end.
  • Service-account JSON key paste-in. Structurally clean (a browser-side RS256 JWT would replace OAuth entirely), but the GCP Console walkthrough to mint a service-account key is substantially heavier than a one-click OAuth consent. iam.disableServiceAccountKeyCreation is enforced by default on GCP orgs created on or after 2024-05-03, making the UX worse over time. Rejected on usability grounds, not security.

What does and does not change with the oauth-broker in place. Compared to the prior design where the App Server held the client_secret and ran the mint:

  • Changed: The App Server is no longer on the credential trust path for Google. A compromise of the App Server no longer exposes any Google OAuth credential or any user's access_token. The blast radius of the most likely compromise is meaningfully reduced.
  • Unchanged: Someone server-side still holds the client_secret. The brief window during which a refresh_token + access_token co-exist in a process's memory still exists; it has just moved from the App Server's address space into a forked child of the oauth-broker. If strict zero-knowledge matters more to you than convenience, do not connect Google, or use a provider whose access is paste-in.

2. GitHub App mode reaches the same posture as Google

Connecting a GitHub fine-grained personal access token (PAT) is fully zero-knowledge — you paste the token into the vault and we treat it like any other paste-in key.

Connecting via the GitHub App OAuth flow has the same trust shape as Google: the App Server holds no GitHub OAuth code or credentials, and the oauth-broker owns the mint, the JWT signing, and the installation-token exchange. The properties carry over verbatim:

  • The App Server holds no GitHub OAuth credentials. No client_id, no client_secret, no App private key, no /authorize endpoint, no /callback endpoint, no per-fetch token mint. A directory grep of the App Server source tree for GITHUB_OAUTH_* or GITHUB_APP_* identifiers comes up empty.
  • The OAuth client_secret and the App's RS256 private key live in the oauth-broker alongside the equivalent Google credentials. They are env-injected at boot; the broker refuses to start if either is missing.
  • Per-fetch token minting runs in a forked oauth-broker child. The User-App path mints a fresh user access_token from the refresh_token + client_secret. The Org-App path additionally signs an App JWT (RS256, in the child) and exchanges it for a 1h installation access_token. The App JWT never leaves the child process; the parent never observes it.

There is one nuance unique to GitHub: Org-App installation tokens are app-wide. Anyone holding a valid App JWT can mint a token for any installation_id that has installed the App, regardless of session. Token possession is therefore NOT the authority for /mint/installation the way it is for /mint/user. The App Server (which still owns the database mapping (user, installation_id)) issues a short-lived HMAC-signed mint grant that binds (user_id, github, installation_id, instance_key, exp=now+60s); the broker verifies the grant against the session user before forking. A leaked grant cannot be redeemed from a different session, and an expired grant cannot be replayed.

The grant flow is a small additional trust hop — the App Server is now the authority on per-row installation ownership — but the App Server never sees the JWT, the access token, or any user-level secret. The grant itself is a 60s authorization assertion, not a credential. The structural property "the App Server holds no GitHub OAuth credentials and runs no GitHub OAuth code paths" still holds.

3. We reuse the PBKDF2 salt across re-encrypts of the same vault

A literal reading of RFC 7518 §4.8.1.1 implies that the PBKDF2 salt (p2s) should be regenerated on every encryption. We do not do that for re-encrypts of an existing vault — we keep the same salt and iteration count, which lets us cache the derived KEK and avoid a 200–500ms PBKDF2 run every time you save a new entry.

Why this is safe: the well-known catastrophe in symmetric encryption is nonce reuse under the same key. We never reuse the AES-GCM content-encryption key or its nonce — both are generated fresh on every encrypt, regardless of whether the KEK is cached. The PBKDF2 salt's job is to make password cracking of this specific vault expensive for an attacker who has stolen the blob; that property is unaffected by salt reuse within a single vault, because the attacker is attacking that one vault anyway. Salt regeneration matters when the same password protects many vaults, which is not our situation.

We document the deviation rather than hide it. The blob is still a valid JWE that any compliant library can decrypt.

4. Residual broker-host risk

A determined attacker who gets code execution on either broker host (the forwarding broker or the oauth-broker) during one of your refreshes — and reads the memory of the right child process in the right ~50ms window — can capture that in-flight credential. The process-per-request design bounds the blast radius to one user's key (or, for the oauth-broker, one user's refresh_token + minted access_token) at one moment, with no historical state to exfiltrate. We are honest that this is not zero.

Full mitigation would require a hardware enclave (Intel SGX, AWS Nitro, or similar) so that the broker itself cannot read the credentials it forwards. That is a substantial engineering and operational investment; we do not have one today. If you are evaluating CostCompass under a threat model that includes attackers with host-level access to our infrastructure during your refresh window, this is the gap you should know about. The two-broker split does not change the per-fetch exposure window in either direction; it does mean that a compromise of one broker does not automatically expose the other's credentials (different process, different docker network seat, different on-disk config), which is a real but limited additional defense in depth.

5. If you forget your vault password, your data is unrecoverable

The vault is encrypted with a key derived from your password. We do not have your password. We do not have a recovery key, a backup, or a side channel to your encrypted data.

This is not an oversight. The alternative is for us to hold something that can decrypt your vault — an escrow key, a recoverable backup, a server-side derivation. Any of those mechanisms means we are no longer a service that cannot read your keys. The whole shape of this design exists to make "we cannot read your keys" structurally true rather than promised.

Mitigation in your hands: the app supports exporting your vault as an encrypted JWE file. Save one, store it somewhere safe, and you have a recovery path that does not require us.

6. We trust your browser

A compromised browser, a malicious extension, or malware running as your user can read anything the page can read. Our Content-Security-Policy restricts scripts to a short, pinned allowlist you can read in our nginx config, the Web Worker isolates the decrypted key from XSS in the main thread, and the WebCrypto API prevents raw key extraction — but if an attacker controls your browser, none of that holds. We list this last not because it is the least important, but because it is the most universal: this is true of every web application you use.

Verification you can do yourself

We do not ask you to take our word for any of this. Concrete checks a skeptical reader can run:

  • Read the broker source. It is in the same repository as the rest of CostCompass. The hostname allowlist is in one file. The signing canonical string is in one function.
  • Decrypt your vault offline. Export your vault, take the resulting .jwe file, and decrypt it with any RFC 7516-compliant library given your password. If it decrypts, the format claims in this document are true. If it does not, we have a bug and we would like to hear about it.
  • Inspect the network panel. During a refresh, you should see your browser making outbound calls to the broker carrying provider keys in Authorization headers, and inbound calls to the App Server carrying response bodies plus signatures — never inbound calls carrying provider keys.

Questions or concerns

If you have questions about anything on this page, find something in the implementation that contradicts what is documented here, or want to report a security issue, email [email protected]. We will keep the "Where we made compromises" section honest as the design evolves.


Last updated: 2026-06-30

Frequently asked questions

Can CostCompass read my provider API keys?

No. Each key is encrypted in your browser with a vault password only you know — a PBKDF2-derived AES-GCM key via WebCrypto — before it is ever sent for storage. CostCompass holds only the resulting ciphertext, which it has no key to decrypt, and your vault password never leaves your browser.

Where are my API keys stored, and in what form?

Only as ciphertext, tied to your account. The plaintext key is reconstructed solely in your browser, and only at the moment you refresh usage, then forwarded to the provider through a broker that holds it just for the length of that one call. It is never written to our database or logs in plaintext.

What happens to my keys if CostCompass's servers are breached?

An attacker would find encrypted blobs, not usable keys. Decrypting one needs your vault password, which CostCompass never receives and never stores — so the stored ciphertext is not a usable credential on its own.

Does my key travel to your servers in plaintext when usage is fetched?

No. When you refresh, your browser decrypts the key locally and routes the request through a hostname-allowlisted broker built not to log key material. The plaintext stays out of the App Server's database and logs; the App Server never holds your plaintext key.