An API key that never reaches the browser
Your API key or each customer's own key, used from your server without ever reaching a browser
For when the answer to “It calls an AI or a paid API with a secret key” is yes.
In SaaS, BYOK or "bring your own key" means each CUSTOMER supplies a provider key and your app holds it in custody.
Skip the rebuild
You do not write this one. It arrives in your project as ordinary source you can read, change and keep, with its audit record and its caveats beside it.
- Download the Playground. It is free and runs on your own machine.
- Start a new app and tick “It calls an AI or a paid API with a secret key”.
- This block is written in, along with anything else you ticked.
The mistake it removes
Two failures, both quiet until they are expensive. A key that reaches the client is public the moment the bundle ships and can only be rotated, never hidden — and it is not just a bundle: a config endpoint, a source map, or an error body echoed from the provider will all do it. And an unbounded call loop against a metered API is a bill with no ceiling, which arrives overnight and is nobody's fault by morning.
What is already handled
Each of these was checked by running the code, not by reading it.
- Unit-tested in this repository's suite: the redactor, the ceiling's refusal boundary (the eleventh $1 hold against a $10 ceiling), the refusal on an unpriced response, and the untrusted-content wrapper.
- Executed, not asserted (ph105tsk08): a mistyped or negative ceiling throws instead of returning unbounded; ten concurrent $1 holds against a $10 ceiling take the whole ceiling as PENDING with $0 yet spent, and the eleventh is refused before any money is spent (the refusal carries statusCode 402); an unpriceable response costs ONE upstream call rather than three and banks the estimate rather than $0. The permitted direction is tested too: a correct ceiling still allows an ordinary call, and every call below the configured rate limit still passes.
- Executed, not asserted (ph105tsk86): the real vault is handed to gradeVault(), the executable form of rubric 21(b), and passes every clause; a record copied from one tenant's slot into another's is refused; a stopped rotation writes nothing; rotation leaves the key's own ciphertext untouched and re-wraps only the data key; callApi sends each tenant's own key and never the owner's; and a provider error echoing a key the pattern redactor cannot recognise reaches the caller without it. No real provider is called (test/vault.test.mjs).
What you still have to do
A copied file cannot own your secrets, your host or your legal obligations. This is the part the block deliberately does not claim.
- Your own authenticated caller id, passed to the rate limiter. This block has no user model, and BYOK_RATE_PER_MIN keyed on anything a caller can write (a header, a query param) is not a limit at all.
- A shared-store (Redis/Upstash) rate limiter once you run more than one instance, plus a spend cap set in the provider's own dashboard. The ceiling and the per-minute limit here are per-process: two instances behind a load balancer each enforce their own, so the effective cap is the cap times the instance count.
- API_KEY must live in your host's environment or secret manager and never in a bundled config file, a source map, or a checked-in .env. The redactor covers what leaves this process; it cannot un-ship a key your build already put in the browser bundle.
- Your own review of anything the model's output is allowed to trigger — an allowlist of tool calls, a human step before an irreversible action. wrapUntrusted() marks content as data the model must not obey, which reduces prompt injection; no delimiter scheme eliminates it.
- The vault encrypts, isolates, rotates and deletes, but three things around it are yours. The master: keep it in a secret manager or a KMS, apart from the store, and rotate it with rotateMaster(). The store: a table only the server can read, with backups treated as holding ciphertext under a master you may later retire. The tenant id: take it from your authenticated session and never from a request, or one customer can spend another's key. Blast radius if the store AND the master leak together: every stored customer key, which means those customers' own provider accounts.
- BYOK, SAID PLAINLY: per-customer key custody is built (node/vault.js, ph105tsk86), passes this repository's vault bar, and was confirmed by an independent verifier, so a search for "BYOK" or "bring your own key" returns this block. What the vault does NOT do: talk to a KMS (the master is a hex key you supply), give you a database table (you pass the store), or keep a separate spend ceiling per customer (BYOK_MONTHLY_USD is one ceiling for the whole process, across every tenant).
- The master is the one secret that opens every record. Keep it out of the store's database, out of the repository and out of any backup of the store. Rotating it re-wraps every record; if any record cannot be opened, rotation writes nothing and names the tenant.
- The spend ceiling counts what THIS app spends. If the same key is used elsewhere the ceiling cannot see it, so it is an upper bound on this app's share, not on the bill.
- callApi() needs `estimateUsd` — a positive upper guess at the call's cost — whenever a ceiling is set. It is held against the ceiling for the duration of the call and replaced by the real price afterwards. Without a reservation the ceiling is a tripwire rather than a cap: every call in flight reads a total none of the others has written to yet.
- If a response cannot be priced, the call still happened and was still billed, so the ESTIMATE is recorded and the error is not retried. That is an approximation of the true cost, and it is deliberate — the alternative is recording a paid call as $0.
- The rate-limit map is keyed on a caller id you supply, so its size is your cardinality, not ours. Stale entries are swept once a minute and a window containing more than 10,000 distinct callers is refused rather than forgotten.
What lands in your project
node
- node/byok.js
- node/vault.js
callApi(): the key read server-side only, a hard timeout, a bounded retry, per-caller rate limiting, a spend ceiling that RESERVES before the call and settles after it, a startup refusal on any unusable setting, and redact() over every log and error. Pass { vault, tenantId } to spend that customer's stored key instead of API_KEY. createKeyVault({ store, master }) in vault.js: put, use, revoke, rotateMaster, list. Plus wrapUntrusted() for the model case.
What you supply
- API_KEY — server-side environment variable. Never import this into anything the bundler can see.
- BYOK_MONTHLY_USD — the spend ceiling in dollars, and it is REQUIRED. Unset, mistyped or negative REFUSES TO START — a ceiling that falls back to unbounded on a typo is worse than none, because the healthy state is silent too. Set a number, or set it to the word `unbounded` to say out loud that you accept a bill with no ceiling.
- BYOK_RATE_PER_MIN — per-caller request cap, keyed on your authenticated user id — never on a header a caller controls. Unset defaults to 20/min, which is still a bound; a value that is not a whole number above zero REFUSES TO START.
- BYOK_MASTER_KEY — the vault's master key, 64 hex characters from `openssl rand -hex 32`, read with masterFromEnv(). Unset or malformed REFUSES TO START. Keep it in your secret manager, never beside the store: whoever holds both holds every customer's key. A KMS belongs here in production.
Licensed MIT. It is a starting point, not a finished product.
Get it
Download the Playground See the other blocks
Nothing here is locked. The files are yours, in your folder, under a permissive license.
Questions
- Is An API key that never reaches the browser audited?
- No. No full audit has been run against this block yet, which is not the same as a pass. What is verified about it is listed on this page, and what is not is listed beside it.
- What do I still have to do myself?
- Your own authenticated caller id, passed to the rate limiter. This block has no user model, and BYOK_RATE_PER_MIN keyed on anything a caller can write (a header, a query param) is not a limit at all.
- How do I get this code?
- Download the Playground, start a new app, and tick “It calls an AI or a paid API with a secret key”. The block is written into your project as ordinary source you can read and edit.
All pre-built code blocks · Learn to build from zero · The coding guide