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:

bash
# .env — must be in .gitignore
FETCH_API_KEY=fetch_your_key
bash
import 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:

bash
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:

bash
curl https://api.zukko.pro/v1/usage \
  -H "Authorization: Bearer fetch_your_key"

If a key does leak

A sequence, not panic:

  1. Go to /app/api-keys and revoke the compromised key — access on it stops immediately.
  2. Issue a new key, set limits and an allowed-model list on it right away.
  3. Check /v1/usage for the last day for requests that don't look like yours (the history is account-wide, with no per-key filter).
  4. 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:

bash
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.

Get API keyDocumentation

api.zukko.pro · fetch_*

Storing API keys safely: real practices · Zukko