An API key leaked into a public repo: the first five minutes
One endpoint contains it, and the order of the next four steps decides whether you also get an outage. A runnable incident script.
An Infrai key in a public commit needs one call, not a deliberation: POST /v1/account/keys/suspected_compromise/{id}, with confirmed_leak and auto_rotate. It’s built for exactly this — contain the credential and issue a replacement in the same request, so the gap between “revoked” and “working again” is as short as your deploy.
Do that first. Read the rest of this page afterwards.
Contain it
curl -sS -X POST "https://api.infrai.cc/v1/account/keys/suspected_compromise/ifr_...4c4a" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"confirmed_leak": true, "auto_rotate": true}'
{
"ok": true,
"data": {
"key_id": "ifr_...9f13",
"key_secret": "your_infrai_api_key_value_shown_once",
"old_key_id": "ifr_...4c4a",
"rotated": true,
"revoked": true,
"action": "suspected_compromise",
"name": "app:prod",
"status": "active",
"created_at": "2026-09-21T02:45:00Z"
}
}
revoked: true on the old id and a fresh key_secret for the new one. Capture that secret now — it’s shown once.
Use confirmed_leak: false when you’re not sure yet. The call still records the suspicion, which matters later when someone asks when you first knew.
Then, in order
- Get the new secret into your services. Deploy it, or write it to your secret store and let the rolling restart pick it up.
- Check what the old key did.
GET /v1/account/usageshows spend by capability for the period; an unexpected spike in a capability you barely use is the tell. - Cap the damage.
PUT /v1/account/budget/setwith a tighthard_cap_usdbounds what any remaining exposure can cost while you finish. - Confirm the old key is dead.
GET /v1/account/keys/listshows itsstatusandlast_used_at; anything still trying it will be gettingKEY_REVOKED.
Only then go back to the repository. Rewriting history is worth doing, but it is not containment — the secret was scraped within minutes of the push, and a force-push doesn’t un-scrape it.
As a script you can run under pressure
import os
import sys
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"] # an ADMIN key, not the leaked one
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
def contain(leaked_key_id: str, cap_usd: float = 25.0) -> dict:
"""Revoke and replace, then bound the blast radius, then report what the key
had been doing. Written to be run by whoever is awake, not by whoever wrote it."""
burned = SESSION.post(
f"{API}/v1/account/keys/suspected_compromise/{leaked_key_id}",
json={"confirmed_leak": True, "auto_rotate": True},
timeout=25,
)
burned.raise_for_status()
replacement = burned.json()["data"]
SESSION.put(
f"{API}/v1/account/budget/set",
json={"hard_cap_usd": cap_usd, "period": "daily",
"alert_threshold_usd": round(cap_usd * 0.5, 2)},
timeout=20,
)
usage = SESSION.get(f"{API}/v1/account/usage", timeout=25)
usage.raise_for_status()
breakdown = sorted(usage.json()["data"].get("breakdown", []), key=lambda r: -r["cost"])
return {
"new_key_id": replacement["key_id"],
"new_secret": replacement["key_secret"],
"old_key_id": replacement.get("old_key_id"),
"revoked": replacement.get("revoked"),
"top_spend": breakdown[:5],
}
if __name__ == "__main__":
if len(sys.argv) < 2:
raise SystemExit("usage: contain.py <leaked_key_id>")
result = contain(sys.argv[1])
print(f"new key {result['new_key_id']} — old {result['old_key_id']} revoked={result['revoked']}")
for row in result["top_spend"]:
print(f" {row['key']:<28} ${row['cost']:.4f} over {row['calls']} call(s)")
Keep that file in your runbook repository. An incident script you write during the incident is a script with a bug in it.
What the key could actually do
This is where scoping pays for itself. A key created with scopes: ["ai", "storage"] can’t send email or SMS no matter who holds it, so the answer to “what’s our exposure” is bounded before you start investigating.
| If the leaked key had | Worst case | Mitigation that was available |
|---|---|---|
| Narrow scopes | spend on those capabilities only | scopes at creation time |
| A budget cap in place | bounded dollars | PUT /v1/account/budget/set |
| No cap, broad scopes | your balance, plus auto-recharge | cap + max_per_day on auto-recharge |
| Auto-recharge uncapped | repeated card charges | max_per_day, max_per_month |
That last row is the one that turns a leak into a finance incident rather than an engineering one. Auto-recharge without max_per_day will keep funding an attacker.
After the fire
Write down when the key was created and when it was last used — both are on the keys list — and keep the request_id values from the containment calls. Then reduce the chance of a repeat: rotate on a schedule with POST /v1/cron/create calling POST /v1/account/keys/rotate/{id}, scope every new key to what it needs, and keep secrets in a store rather than a repository.
The limitation worth naming: none of this tells you who used the key or from where, beyond last_used_ip on the key record. There’s no per-request audit log you can subpoena, so attribution beyond “this key, this IP, this capability spend” isn’t available — if you need full request-level forensics, log it yourself at the call site.
What is on the same credential is the rest of the response: the alert email via POST /v1/email/send, the incident record via POST /v1/errors/capture, and the scheduled rotation on POST /v1/cron/create — one key, one bill, one place to prove you acted. Every route in this playbook reports billing_class: free in discovery, so containment costs nothing; the spend it protects is live in GET /v1/account/balance (verified 2026-09-21), where rates drift downward over time rather than up.