Storing API keys safely: practices, not just advice
Everyone's heard "don't commit keys to git." Here's what comes after that: per-key limits, model restrictions, and an actual plan for when a key leaks anyway.
4 min · · Updated · security
"Don't commit your keys" is advice everyone knows and still breaks sometimes: a copy-paste into a public gist while asking for help, a forgotten .env in a project archive, a terminal screenshot with the token still visible. The difference between a leak that's an annoyance and one that's a disaster comes down to what was configured beforehand, not whether the key got out.
A key is a bearer token, not a password with MFA
The format is fetch_..., and the header is the same for both modes:
Authorization: Bearer fetch_your_key
Anthropic clients sometimes send it as x-api-key instead — the gateway accepts both, so there's no reason to keep separate keys for separate header styles. What actually matters: a bearer token has no second factor. Whoever holds that string can make requests as you until you revoke it. The key is shown once, at creation, in the dashboard — if you didn't save it to a password manager right away, there's no viewing it again, only issuing a new one.
Per-key limits aren't a paranoid extra, they're a baseline setting
On the key's page under /app/api-keys you can set a daily and monthly spend limit in dollars and a list of models that key is allowed to call. Set both at creation time, not after an incident:
- A dev-environment key: something like a $2/day cap, access limited to cheap models for testing.
- A production key: a limit sized to expected load plus headroom for spikes, but not "unlimited."
- A key for a client demo: a hard ceiling that physically prevents a leak from turning into a real bill.
If the limit is hit, requests on that key return 429 with key_daily_limit_reached or key_monthly_limit_reached — the service won't charge past the threshold you set, no matter how many requests come through. If the key calls a model outside its allowed list, the response is 403 model_not_allowed_for_key. That means even a stolen key can't be run against an expensive model unless you already granted that permission.
Where the key actually lives in code
Environment variables, not constants in a file:
# .env — must be in .gitignore
FETCH_API_KEY=fetch_your_keyimport os
api_key = os.environ["FETCH_API_KEY"]For Claude Code it's ANTHROPIC_AUTH_TOKEN (not ANTHROPIC_API_KEY — with that one the client assumes you want to log in through an official account). For OpenAI-SDK-based tools it's OPENAI_API_KEY, regardless of which provider is actually behind the base URL.
Before the first commit, check the repository's history, not just the current state of the files — a key can have been exposed in an old commit and stay in history even after it's removed from the working copy:
git log -p --all | grep -i "fetch_"If anything turns up, treat the key as compromised regardless of whether the repo is public or private — anyone who ever cloned it could have had access.
Idempotency-Key protects against double-charging, not theft
The Idempotency-Key header is about reliability, not security: replaying a request with the same value within 24 hours returns the same response without charging again. It's useful for network drops and retries, but it does nothing to stop a third party with a stolen key from sending their own requests — those are different problems, and treating one as a substitute for the other is a mistake.
Monitoring instead of hoping
The X-Zukko-Cost-Micros header (cost of that request in microdollars) and X-Fetch-Balance-Cents (remaining balance after the charge — despite the name, also in microdollars: 1,000,000 = $1) come back on every regular response; streaming responses (stream: true) don't carry them — for those, check spend in the dashboard, /v1/usage or /v1/balance. Log them on your end — a sharp spike in spend without a matching rise in your own traffic usually means the key is being used by more than just you. Charge history with pagination (shared across the whole account, all keys together) is available separately:
curl https://api.zukko.pro/v1/usage \
-H "Authorization: Bearer fetch_your_key"If a key does leak
A sequence, not panic:
- Go to
/app/api-keysand revoke the compromised key — access on it stops immediately. - Issue a new key, set limits and an allowed-model list on it right away.
- Check
/v1/usagefor the last day for requests that don't look like yours (the history is account-wide, with no per-key filter). - Update the environment variables everywhere the old key was used and redeploy.
It's cheaper to issue a new key at the first hint of doubt than to guess whether the old one is still sitting somewhere in the wrong place.
Split keys by purpose
One key for the entire stack is the most common way a small leak turns into a big one. A separate key per project or environment, each with its own limit, contains the damage to a single project instead of the whole account. When something goes wrong, revoking one key is a lot simpler than figuring out what breaks if you revoke the shared one.
Application logs leak keys just as often as git does
A key rarely leaks dramatically — more often it just quietly settles somewhere nobody expected. If middleware logs incoming request headers for debugging, Authorization: Bearer fetch_... or x-api-key can end up whole inside a log aggregator like Sentry or Datadog, which usually has broader access than the repository itself: support staff, analysts, sometimes contractors look at it. The rule is simple — mask those header values before they're written to the log, not after:
SENSITIVE_HEADERS = {"authorization", "x-api-key"}
def sanitize_headers(headers: dict) -> dict:
return {
k: ("***" if k.lower() in SENSITIVE_HEADERS else v)
for k, v in headers.items()
}The same logic applies to error traces: if an exception dumps the full request object and that object's headers include the key, an error-monitoring stack trace becomes just as much a leak point as a stray commit.
zukko
Pay 25×
less
for Claude Code & ChatGPT
One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.
Related articles
- opencode agents: setting up roles, models and toolsA custom agent in opencode is a role, a model and a set of permissions. A working config where a cheap model handles routine work and a stronger one edits code.
- OpenAI API Keys in the Open: Why You Should Not Use ThemWhere free keys on GitHub come from, why using them is dangerous for you and for the owner, and how to protect your own key from the same leak.
- GLM API Errors: Why the Request Fails and How to Fix ItA wrong model id, an overflowing 128K context, the key, the balance, limits: the typical causes of GLM API errors and a quick way to find yours.
- Plan mode in Claude Code: plan first, then edit the codeIn plan mode Claude Code reads your code and proposes a plan but edits nothing until you agree. How to enable it, use it and what it costs.