Blast Radius of an AI Agent's API Key: Score It in 40 Lines
Source: Dev.to
The blast radius of an API key is not “did it leak.” It’s “if the agent holding it does the wrong thing, how much of your stack goes with it.” A secret scanner answers the first question. Nothing in your toolchain answers the second one before an incident. So I wrote 40 lines that do, offline, from the permission metadata you already have.
In short: the blast radius of an API key is set by its permissions, not by whether it leaked: scope width × environment isolation × lifetime × revocability. blast_radius.py reads that metadata (never the secret value, never the network) and scores each key 0–100. In my run a broad prod token hit 91/100 CRITICAL; a scoped key, 14/100 CONTAINED. Stdlib, keyless, deterministic.
AI disclosure: I wrote blast_radius.py with AI assistance and ran it myself before publishing. Every score in the output blocks below is pasted from a real run on a synthetic fixture I’ll show you — no real keys exist in it; every value is a placeholder like sk-FAKE-…. The incidents I cite ($82K, 9 seconds, 2,863 keys, 93%) are other people’s, and I link each source next to it. I label which is which.
A token that could delete the database, used to manage domains
On April 24, 2026, a Cursor agent running Claude Opus 4.6 dropped a production database, and the volume backups with it, in about 9 seconds, on one API call. The team behind PocketOS wrote it up. The agent hit a credential mismatch, went looking, and found a long-lived Railway CLI token sitting in an unrelated file. That token had been minted to manage domains. But it carried blanket authority over the whole Railway account, volumeDelete included. No scope tied to an environment. No read-only mode. Recovery took the weekend (The New Stack, 27 Apr 2026; The Register, 27 Apr 2026).
Read that again, because the lesson isn’t “the agent went rogue.” The lesson is the token. A domain-management task does not need volumeDelete. The key was over-scoped before the agent ever touched it. The agent didn’t expand the blast radius. It just walked into the one a human had already set up.
Here’s the uncomfortable part. You can’t see that on a dashboard. A spend dashboard shows what a key costs. A secret scanner shows whether a key leaked. Neither one shows you that a single legal key, never leaked, sitting exactly where it belongs, can take down prod in 9 seconds because its scope is wider than its job. That gap is the whole reason this tool exists.
What this scores — and what it refuses to do
I want to draw a hard line first, because this lives one inch away from a category I am not in.
This is not a secret scanner. It does not grep your repo for sk-… or AKIA…. It never reads the secret value at all. It reads the permission metadata around a key (the scopes it was granted, which environments it appears in, whether it expires, whether you can revoke it) and scores how much would break if that key were misused. Nothing leaves the machine. No provider is contacted. No key is validated online.
The distinction matters because the tooling shelf for “did a secret leak” is crowded and good. GitHub-native secret scanning went GA in May 2026, and there are MCP-focused scanners too. They answer “is this key exposed?” blast_radius.py answers a question none of them ask: “this key is exactly where it should be, so if it’s ever misused, how much do I lose?” Having a key and scoping a key are different problems. The first one a scanner covers. The second one nobody was measuring before the incident, which is exactly when measuring is free and afterward is a postmortem.
So the franchise I keep returning to on this blog, tracking ≠ control, gets a sibling here: having ≠ scoping. The question isn’t whether your agent holds a key. Of course it does. The question is how wide it acts if something goes wrong.
The four axes
The score is four questions, each worth 0–25, summed into a 0–100 blast-radius index. Each axis is a thing you can read off metadata without ever touching the secret. Here’s the scope axis, whole:
WILDCARD_MARKERS = ("*", "admin", "root", "owner", "blanket", "full")
def score_scope(cred: dict) -> tuple[int, str]:
"""Axis 1 - scope width: how many actions/resources the key may touch."""
scopes = cred.get("scopes") or []
if any(any(m in str(s).lower() for m in WILDCARD_MARKERS) for s in scopes):
return 25, "wildcard/admin scope (touches everything)"
destructive = cred.get("destructive_actions") or []
if destructive:
return 18, f"named scope but includes destructive verbs ({', '.join(destructive[:3])})"
return 4 if len(scopes) tuple[int, str]:
envs = cred.get("environments") or []
shared = cred.get("shared_with") or []
if len(envs) >= 3 or (len(envs) >= 2 and shared):
return 25, f"one key spans {len(envs)} envs {envs}, shared with {len(shared)} consumer(s)"
if len(envs) == 2:
return 14, f"shared across {envs} (dev slip reaches the other)"
return 3, f"isolated to {envs or ['(none declared)']}"
Enter fullscreen mode
Exit fullscreen mode
Lifetime asks whether the key ever expires. A key with no TTL keeps its blast radius open forever, while a 7-day token closes the window on its own. Revocability asks whether you can kill it fast and see what it did, because if you can’t revoke it and can’t audit it, the blast radius lasts as long as the incident does. A key with revocation: "none" is a key you can only watch burn.
That’s it. Four functions, four metadata reads, no secret ever parsed. The scoring core is about 40 lines; the rest is the report.
Running it
The demo reads a synthetic fixture — three credentials, every value a sk-FAKE-… placeholder — and scores each one. No real key exists in that file. Run it:
python3 blast_radius.py --fixture agent_env_fixture.json
Enter fullscreen mode
Exit fullscreen mode
The three credentials are the three patterns from the incidents: one scoped key done right, one broad shared prod key (the Railway/PocketOS shape), and one no-expiry no-revocation key (the silent-scope-expansion shape).
The scoped key — what good looks like
First, the one that should score low, so you trust the meter isn’t just alarmist:
MAPS_API_KEY [SCOPED-OK]
--------------------------------------------------------------------------
scope-width 4/25 narrow named scope (1 action(s))
env-isolation 3/25 isolated to ['prod']
lifetime 4/25 short-lived TTL 7d, auto-rotated
revocability 3/25 self-serve revocation
BLAST INDEX 14/100 band: CONTAINED
Enter fullscreen mode
Exit fullscreen mode
Fourteen out of a hundred. One scope (geocode:read), one environment, a 7-day TTL that rotates itself, a revoke button you can press yourself. If this key is misused, the damage is bounded to read-only geocoding for seven days. That’s a blast radius you can live with. The point of showing it first: the tool isn’t stricter-is-better. It’s measuring a real axis, and on a well-scoped key it says so.
The broad shared prod key — the 9-second key
Now the Railway shape. One token, wildcard scope, living in dev and staging and prod, shared with three consumers, never expires, revocable only via support ticket:
APP_DB_TOKEN [BROAD-shared-prod-key]
--------------------------------------------------------------------------
scope-width 25/25 wildcard/admin scope (touches everything)
env-isolation 25/25 one key spans 3 envs ['dev', 'staging', 'prod'], shared with 3 consumer(s)
lifetime 25/25 never expires (blast radius stays open indefinitely)
revocability 16/25 revocation via support ticket only (slow); no audit log
BLAST INDEX 91/100 band: CRITICAL
Enter fullscreen mode
Exit fullscreen mode
Ninety-one out of a hundred. Three axes maxed. This is the PocketOS token before the incident: measurable as CRITICAL on the day it was created, not the day it deleted the database. If you’d run this against your real .env metadata last Tuesday, it would have handed you the exact key to fix this Tuesday. The score doesn’t predict the agent will misbehave. It tells you what you lose if it does, and here the answer is: everything, in every environment, with no fast off-switch.
The no-expiry key — silent scope creep
The third one is subtler, and it’s the $82K shape. A key minted for one job that quietly gained access to another. In February 2026, a stolen Gemini API key ran up $82,314 in 48 hours, about 457× a normal $180/month, because a key created for Google Maps silently gained access to the Generative Language API when that API was enabled, with no spend cap on the provider side. A three-person team nearly went under (The Register, 3 Mar 2026). Truffle Security then found 2,863 public GCP keys that had silently gained Gemini access through the same mechanism; Google reclassified the behavior from “intended” to “Bug” (Truffle Security, 25 Feb 2026). My fixture models that key — broad scope, no expiry, no revocation path:
GEMINI_GENAI_KEY [NO-EXPIRY-NO-REVOCATION]
--------------------------------------------------------------------------
scope-width 25/25 wildcard/admin scope (touches everything)
env-isolation 3/25 isolated to ['prod']
lifetime 25/25 never expires (blast radius stays open indefinitely)
revocability 25/25 no revocation path; can't be killed in an incident
BLAST INDEX 78/100 band: CRITICAL
Enter fullscreen mode
Exit fullscreen mode
Seventy-eight. Note env-isolation is a clean 3 here. This key lives in one environment, so it’s better isolated than the Railway token. And it still lands CRITICAL, because wildcard scope plus no-expiry plus no-revocation is its own kind of fatal. Two different keys, two different reasons, both in the red. That’s the value of breaking the score into axes instead of one number: it tells you why a key is dangerous, which is the same as telling you what to fix.
The ranking and the contrast
The report ends with the part you’d actually act on — what to scope down first, and how your fixture compares to the field:
RANKING (scope these down first - highest blast radius at top)
------------------------------------------------------------
1. APP_DB_TOKEN 91/100 CRITICAL [BROAD-shared-prod-key]
2. GEMINI_GENAI_KEY 78/100 CRITICAL [NO-EXPIRY-NO-REVOCATION]
3. MAPS_API_KEY 14/100 CONTAINED [SCOPED-OK]
CONTRAST (scoped vs unscoped)
this fixture: 2/3 keys carry wildcard-or-destructive scope (67% of keys)
audit benchmark: 93% of keys unscoped (Grantex, State of AI Agent Security 2026 ...)
Enter fullscreen mode
Exit fullscreen mode
Two of three keys in my fixture carry wildcard-or-destructive scope. The benchmark line next to it is a real audit: Grantex’s State of AI Agent Security 2026 (15 Mar 2026) looked at 30 top open-source agent projects (500k+ combined stars) and found 93% of keys unscoped (28 of 30), 97% with no user-consent step, and zero projects assigning a unique per-agent identity (Grantex, 15 Mar 2026). My 67% is the synthetic fixture’s number; the 93% is theirs. I keep them on separate lines on purpose. One is a demo, one is the field, and conflating them would be exactly the kind of number I tell you not to trust.
Why offline, why no vendor
You could buy this. The least-privilege space is full of identity platforms (Okta, Aembit, Zscaler-adjacent products) that sell scoping as a layer you migrate into. Those are real and sometimes worth it. But you don’t need to adopt a platform to find out your blast radius. The metadata is already in your .env, your MCP server config, your agent’s tool spec. blast_radius.py reads what you have and scores it, in your process, with nothing sent anywhere. It’s the difference between a smoke detector you install in an afternoon and a fire-suppression contract. Install the detector first.
This sits next to the other layers I’ve built here, and it’s the one that runs earliest. The pre-execution gate decides whether a specific action is allowed to run. Scope is about what the key can reach; the gate is about what the agent actually does with it. The sliding-window spend guard bounds the dollar blast radius over time; this bounds the permission blast radius before the first call. And SpendGuard caps what one call can cost. Scope narrows what the key can touch at all; the spend cap narrows what it costs when it touches it. Two layers of the same wall.
What this is NOT
I’d rather you trust the small honest claim than oversell it.
It’s not a secret scanner, and it’s not a security audit. It scores the breadth of a legal key’s permissions, not whether anything leaked. The score is a heuristic for ranking “what to scope down first,” not a safety guarantee. False positives exist: a key that scores 25 on revocability might have an out-of-band kill switch the metadata doesn’t capture.
The secret value is never scored or sent. The scorer reads scope/env/expiry/revocation fields and nothing else — it never parses, scores, prints, or transmits a key’s value. If you point it at your own .env, strip the values to metadata first. The tool doesn’t need them and is built not to want them.
The fixture is synthetic. Every key is a sk-FAKE-… placeholder. The scores are real (a real, deterministic run — same input, byte-for-byte same output across runs), but they describe three modeled patterns, not a live system. Your numbers depend on your actual grants.
The axis weights are opinions. I weight scope and lifetime heavily because that’s what the public incidents turned on. You may disagree, and you can: the four functions are independent. If you think revocability should outweigh scope, change one return value. I’d genuinely like to hear which weighting you’d argue for.
The incident numbers are other people’s, with sources. The 9 seconds, the $82,314, the 2,863 keys, the 93%: every one is linked to a primary report with a date. The only numbers I generated are the scores: 91, 78, 14, and the 67% contrast. I label which is which because that line is the whole point of this blog.
Score your worst key first
Open your agent’s .env and its MCP config. For each key, write down four things (its scopes, its environments, whether it expires, whether you can revoke it) and run them through the scorer. The one that comes back highest is the one a misbehaving agent, or a stolen credential, turns into a 9-second incident. Narrow its scope, give it a TTL, and watch the number drop. That’s the cheapest security work you’ll do all quarter, and it runs before anything goes wrong instead of in the postmortem after.
Here’s the open question I haven’t settled, and I’d like your take: how do you split dev, staging, and prod keys for an agent — or is it one key doing everything? Every incident I read traced back to a key that was wider than its job, and “just use separate keys” is easy to say and weirdly hard to hold the line on once an agent needs to touch six services. If you’ve found a key-per-environment discipline that actually survives contact with a real agent, drop it in the comments. I read every one. And follow along; the next post takes this scorer from a fixture to a real MCP config and tries to find where the scoping wall should actually live.