← All posts

RUNTIME SECURITY

The Key Your Scanner Swore Wasn't There

I built KeyLeak Detector after watching a live Stripe sk_live_ secret key get pulled out of an app whose GitLeaks, TruffleHog, and GitHub scans were all green — because none of those scanners were ever built to look at the running app.

I still remember the exact shape of the pit in my stomach the first time I watched it happen. Every source scanner was green. GitLeaks on every commit, TruffleHog in the pipeline, GitHub secret scanning watching the repo — clean, clean, clean. Then someone opened the production site, popped dev tools, and read a single minified JavaScript file. Sitting in it, in plaintext, was a live Stripe sk_live_ key. Full API access. Real money. Under a minute.

What got me wasn't the speed. It was the slow, sinking realization that none of those green checkmarks were ever going to catch this, no matter how well they were configured. They were looking in the wrong place by design.

Here's the detail that makes it sting. There was already a Stripe key in that same bundle, and it was supposed to be there. The publishable key, pk_live_, is meant to ride along in the browser — it can only create payment intents the backend later confirms. What leaked next to it was the secret key, sk_live_, which can refund charges, read customer records, and move money on its own authority. Two keys, near-identical at a glance, one benign and one catastrophic. The real skill in that minute wasn't finding a key. It was knowing which key it was.

The secret was never in the repo. Not in a commit, not in git history, not in any file a source scanner could read. It was injected at build time and shipped to every visitor. I've lost count of how many times I've seen that exact shape since, and the lesson never gets less uncomfortable: your source code can be spotless while your running app bleeds.

Not every key in the bundle is a leak

Let me draw the line most tooling gets wrong, because I see it blurred constantly: a key in the browser is not automatically a vulnerability. Public-by-design keys are fine in the bundle. A Stripe publishable key (pk_live_), a Supabase anon key, a Firebase web config, a Google Maps browser key — these are built to be shipped. Their safety lives not in secrecy but in server-side controls behind them: Row Level Security, API key restrictions, allowed-origin rules. Flagging them is noise. Secret keys are catastrophic in the bundle. A Stripe secret key (sk_live_), a Supabase service_role JWT (which bypasses RLS entirely), an AWS secret access key, an OpenAI key — these carry the full authority of your backend; in a browser they bypass auth, move money, or read everything.

So the question I keep coming back to is never "is there a key in this bundle?" It's "was this supposed to ship, or supposed to stay on the server — and if it shipped by mistake, does it actually work?" Telling those apart, at runtime, on the artifact users receive, turned out to be the entire job.

Why I stopped blaming the scanners

For a while I wanted this to be a tuning problem. It isn't — you cannot regex your way out of it. GitLeaks, TruffleHog in repo mode, and GitHub secret scanning all operate on source files and git history, and they are genuinely very good at that. But a whole class of secrets only comes into existence after the build: environment variables inlined into bundles, runtime config fetched when the app boots, values returned in API responses and headers, and original variable names and routes re-exposed by source maps.

The artifact a source scanner would need to inspect — the minified bundle, the hydrated config object — does not exist in the repository. It's manufactured at build time and lives only in the deployed app. Intruder's research frames the gap bluntly: SAST tools "don't cover the full picture; secrets within JavaScript bundles slip through gaps," and infrastructure scanners "ignore JavaScript files that browsers would load." You're asking a tool to find something in a place it was never written. Once that clicked for me, I stopped being annoyed at the scanners and started being worried about everything past them.

Why this barely existed ten years ago

This blind spot isn't eternal, and understanding where it came from is what convinced me it's structural rather than a run of sloppy teams. In the server-rendered monolith era — Rails, Django, PHP, classic ASP.NET — the browser received only rendered HTML. Secrets sat in server-side environment variables and config, were read by code that ran on the server, and never crossed to the client. There was no build step that substituted a secret's value into JavaScript the browser would download; the default data flow kept secrets behind the network boundary.

Then the architecture inverted. Single-page applications moved rendering and logic into the browser, and the Jamstack model prebuilt the front end into static assets talking directly to APIs. Configuration that used to live on a server now had to reach client code — and modern build tools solved that with compile-time environment-variable inlining: at build time they find references like process.env.X or import.meta.env.X and replace them with the literal string value, baking it permanently into the shipped bundle. Useful feature; also the exact mechanism that turns a mis-prefixed secret into plaintext on every visitor's machine.

Every major framework guards this with the same kind of safeguard — a prefix is the one thing separating "server-only" from "shipped to every browser." Create React App embeds only REACT_APP_ variables ("ignored to avoid accidentally exposing a private key"). Next.js inlines NEXT_PUBLIC_ variables "into any JavaScript sent to the browser." Vite exposes only VITE_-prefixed variables, warning "to prevent accidentally leaking env variables to the client, avoid using this prefix." Vue CLI embeds only VUE_APP_ variables; Gatsby requires GATSBY_ "to expose a variable in the browser"; Nuxt exposes only keys under runtimeConfig.public; SvelteKit exposes only PUBLIC_ variables via $env/static/public; and Expo inlines EXPO_PUBLIC_ values, warning "do not store sensitive info, such as private keys, in EXPO_PUBLIC_ variables."

I want to be fair about what this is: the prefix is a safety mechanism, not a flaw. Frameworks default to private and require an explicit, named opt-in to publish. The footgun is human — name a server secret NEXT_PUBLIC_STRIPE_SECRET, or simply misread "public" as anything less than "every browser forever," and you've hand-delivered a secret key to the client. One wrong prefix is the whole vulnerability, and the prefix is a string a developer types under deadline. And crucially, the artifact that then holds the secret — the built bundle — only exists after the build, so a source or commit scanner structurally cannot see it.

How secrets reach the runtime but never the repo

That history shows up today as three concrete delivery paths I keep tracing back, every one of which puts secrets in front of users while keeping the repository clean.

Build-time inlining. As above, Next.js (NEXT_PUBLIC_*) and Vite (VITE_*) replace env-var references with their literal string values at build and bake them into the client bundle. Give a server secret the wrong prefix — NEXT_PUBLIC_STRIPE_SECRET=sk_live_... — and the literal key is hardcoded into shipped JavaScript. The repo only ever held process.env.NEXT_PUBLIC_STRIPE_SECRET, a perfectly innocent-looking reference. The scanner finds nothing because there is nothing to find. Vercel now shows dashboard warnings precisely because this footgun is so common.

Edge and serverless config injection. On Vercel, Netlify, and Cloudflare, env vars live in the platform dashboard and are hydrated at deploy — never committed. That's good hygiene for server secrets. But the moment one reaches the client through inlining or a leaky public config endpoint, the only place to catch it is the running deployment, because it was never in a file git ever saw.

BaaS clients that ship keys by design. Supabase anon keys and Firebase web configs are meant to be in the browser — the safe kind from the opening section. The danger is the look-alike beside them, the service_role JWT, and the server-side rules that are supposed to be backing them up. More on why that's its own trap below.

Source maps make all of this worse. When shipped to production by accident — extremely common — they reverse minification, restoring original variable names, file paths, and inlined constants, handing an attacker a readable map of your internal API routes. Source maps live in the deployed artifact, not the source tree a commit scanner reads.

Public-by-design is not safe-by-default

This is where my own intuition used to fail, so I'll be blunt about it. A Supabase anon key is supposed to be public. The security boundary was never the key — it's the server-side Row Level Security policy behind it. A static scanner sees a string and has two options: ignore it (correctly, it's "public") or flag it without any way to reason about whether the backend is actually locked down.

And the default is exposed. Supabase auto-generates a REST API from your Postgres schema, but Row Level Security is off by default on tables created via SQL or the Table Editor. The app works flawlessly while unauthenticated requests carrying the public anon key quietly read and write protected rows. You cannot see this in source code. The only way to know is to issue a live, unauthenticated request and watch protected data come back. That question — does this public key return data it shouldn't? — is the dimension static tools cannot supply.

Exploitability is the real signal

A pattern match is a guess. A live probe is proof. The question I learned to ask is not "does this look like a key?" but "is this key live, and what can it touch right now?"

This isn't theoretical. TruffleHog built its reputation on verifying detectors that authenticate against the real service and classify findings as verified, unverified, or revoked. Runtime scanning extends that idea to the deployed app itself: extract the secret and prove it works, probe whether a public BaaS key returns protected rows, and tell an active threat apart from a dead string. That's the difference between a noisy candidate list and a ranked incident queue.

And dead strings are rarer than anyone hopes — I assumed leaked keys mostly got rotated, and the data corrected me hard. GitGuardian retested credentials it had confirmed valid in 2022 and found the validity rate was still above 64% in January 2026 — with 70% of 2022-leaked secrets still active two years on. Leaked keys are almost never rotated. Exploitability is not a corner case; it's the norm.

The gap is widening, not closing

For a while I told myself this would self-correct as tooling caught up. It won't — the same forces that created the blind spot keep enlarging it. SPAs and client-heavy frameworks move more logic and config into the browser. Edge and serverless platforms normalize injecting config outside git. Backend-as-a-service — Supabase, Firebase — normalizes "keys in the browser" as routine, which is correct for anon and web-config keys but quietly trains developers that any key in a bundle is fine, blurring the public/secret line I opened this post on. And AI app generators ship front-end-first apps faster than ever, frequently with security defaults off and nobody checking which prefix went where. Every one of these relocates secrets and trust boundaries from the repo — where static tools look — to the running app, where they can't.

The leak volume confirms the direction of travel. GitGuardian recorded 28.65 million new hardcoded secrets in public GitHub commits in 2025 — a 34% year-over-year jump — and AI-service secrets alone hit 1,275,105 detections, up 81%. That's just what was visible in public commits. The runtime surface is the part nobody is counting.

Truffle Security scanned 400TB of web data and found 11,908 live secrets across 2.76 million pages — including roughly 1,500 Mailchimp API keys hardcoded directly into front-end HTML and JavaScript.

It wasn't just me — the record backs it up

For a while I wondered if I was just unlucky in what I kept stumbling onto. The public record settled it: this isn't a hypothetical risk pattern, it's being exploited at scale, and the evidence is everywhere you point a runtime probe.

  • Intruder remotely scanned around 5 million applications and surfaced 42,000+ exposed tokens across 334 secret types — live GitHub and GitLab tokens, active Slack webhooks, and more, all living in JavaScript bundles that SAST and infra scanners structurally miss.
  • Cremit's study of Vercel-hosted services found a small but lethal slice shipping valid production secrets — Stripe sk_live_ keys, GitHub tokens, AWS secret access keys — straight into client bundles via NEXT_PUBLIC_ misuse. The exact shape I'd been watching.
  • CVE-2025-48757: researchers found Supabase tables across many Lovable-generated apps that were readable by unauthenticated requests using the public anon key — missing RLS, provable only at runtime.
  • Misconfigured Firebase has exposed enormous troves of user records and plaintext passwords across thousands of sites — a pure BaaS-rules failure invisible to source scanning.
  • Cyble found roughly 3,000 live production websites exposing active OpenAI API keys directly in client-side JavaScript.
  • The Rabbit R1 leaked hardcoded API keys that stayed valid well after notification; a DOGE developer's xAI key, found externally by GitGuardian, remained live even after public disclosure. The detection happened — too late, and the key kept working.

And to be honest about why "too late" is the default outcome: in a controlled honeypot, an AWS key posted to a public repo was discovered and abused within one minute of exposure. The same minute it took to pull that sk_live_ key out of dev tools.

Keep your source scanners — I do

Let me be clear about the framing, because I'd reject the false binary too. Source scanners catch secrets before they ship and protect git history — keep every one of them. Runtime scanning catches what survives the build and reaches users, and proves exploitability. "Use this instead of GitLeaks" is wrong. The posture I trust runs both: shift-left prevention on commits, plus deploy-time verification of the actual artifact users receive. Runtime scanning is the missing right-hand bookend of the pipeline.

So I made it a launch gate

The operational home for this, in my own workflow, is a CI gate against the preview or staging deploy, after build but before promote-to-prod. Fetch and inspect the real built bundle. Hit the live endpoints the way an attacker would. Validate any key you find — and, critically, distinguish a public-by-design key that belongs there from a secret key that doesn't. Probe BaaS tables for missing RLS. Then fail the gate on a verified-live, high-severity finding — per PR, on the exact artifact your users will get. A commit scan can never inspect that artifact, because it doesn't exist yet at commit time. This gate catches the inlining mistake and the RLS regression on the build that ships.

One objection nearly stopped me: security teams will not send their bundles and keys to a third-party cloud, and they're right not to. So it can't ask them to. A runtime scanner that runs 100% locally — browser extension, CLI, and self-hosted Action — does live validation against the target's own endpoints without exfiltrating anything. That's what makes a deploy-time gate something a security team will actually adopt.

Scan what attackers scan

Here's the one line I kept after all of it: attackers don't scan your repo. They scan your running app. So scan it first — and scan it the way they would, against the live deployment, proving which keys are actually exploitable and which were always meant to be there.

That green-scanner, live-sk_live_-key gut punch is exactly why I built KeyLeak Detector — a free, open-source runtime secret scanner for this gate. It finds exposed API keys in JS bundles, tells the public-by-design keys apart from the secret keys that don't belong, validates Supabase and Firebase RLS misconfigurations, and tests whether a found key is still live — all 100% locally, as a Chrome extension, a Python CLI, and a GitHub Action you can wire into your preview deploys. Point it at your staging URL before you promote to production. The code is on GitHub. Keep your source scanners. Add the bookend they were never built to be.

Sources

  1. GitGuardian, The State of Secrets Sprawl 2026
  2. GitGuardian, The State of Secrets Sprawl 2025 (press release)
  3. Intruder, Secrets in your Bundle(.js)
  4. Truffle Security, 12,000 Live API Keys in DeepSeek Training Data
  5. TruffleHog, How TruffleHog Verifies Secrets
  6. Cyble / The Cyber Express, Exposed ChatGPT API Keys
  7. Comparitech, GitHub credential honeypot
  8. Vercel, Public environment variable warnings