The leak your repository scanner cannot see
NEXT_PUBLIC_ and VITE_ substitute the literal value into the bundle at build time. The repository stays clean and the key ships to every visitor. What that means, and how to check your own deployment.
Two files, and only one of them is scanned
NEXT_PUBLIC_ and VITE_ are not access-control settings. They are instructions to the bundler: take this literal value and write it into the output. Your .env file stays git-ignored, your repository stays clean, and the key ships to every visitor inside a JavaScript chunk.
That is the whole mechanism, and it explains why a green repository scan and a leaked credential are not contradictory findings. They are statements about two different files. The scanner read the one you commit. The browser downloads the one your build produced.
A second failure mode compounds it. Generic pattern scanners flag credentials that are supposed to be public — the Supabase anon key, the Stripe publishable key, Firebase web constants — and after the third false alarm the team tunes the tool out. The real finding then arrives into a channel nobody reads.
Why this leak is tool-shaped
The path a secret takes into a bundle usually says something about how the app was built.
Lovable and Bolt produce Vite single-page applications with no server component, so there is nowhere else for a key to live if the call has to succeed. Cursor and similar assistants edit the file you have open rather than the architecture around it, so the working change is local and the structural problem is not addressed. In Next.js, the fastest way to clear a build error about an undefined environment variable is to rename it with the NEXT_PUBLIC_ prefix — which fixes the error by publishing the value.
None of this is carelessness. It is the shortest correct-looking path in each case, which is exactly why it recurs after being fixed.
What the scanner does and does not do
KeyDrift runs 24 detectors against the JavaScript your deployment actually serves: the HTML, every referenced chunk including ones named only in the route manifest, and the server-streamed data frameworks inline into the document.
It also catalogues five credential formats that are public by design — the Supabase anon and authenticated keys, the Supabase local-development service_role key, the Supabase publishable key, the Stripe publishable key, and the Firebase web key — and reports them as informational context excluded from the finding count. That taxonomy is the point. Knowing which strings belong in a browser is what makes it credible to say that the JWT sitting beside them does not.
Two boundaries are worth stating plainly, because a scanner that overclaims is worse than none. It sees deployed web artifacts only: no mobile binaries, no private networks, no repositories, nothing behind a login except through paste mode. And detection is pattern, entropy and context rather than a JavaScript interpreter — a key assembled at runtime from fragments is outside the honest reach of any bundle scanner, this one included.
Findings without a copy of your secret
No live key is stored. Each finding carries a masked prefix, a salted fingerprint for tracking the same credential across scans, the exact chunk filename it appears in, and a severity with written rationale. Scanning issues GET requests against public assets and nothing else.
The fingerprint is what makes the next section possible: it is how two scans a week apart can agree that a finding is the same finding.
Fixing it once is not the same as it staying fixed
Rotate first — always. A credential that has been served publicly should be treated as compromised regardless of what the logs show, because absence of observed abuse is not evidence of absence.
Then move the call to a server route, an edge function or an API handler so the value never enters the client build, and confirm with a fresh scan of the deployed result rather than a passing test.
The part teams underestimate is durability. The pattern that produced the leak is still in the codebase's gravity, and the next feature request that needs the same query to return rows will reproduce it. Scheduled re-scans diff consecutive runs and distinguish three transitions: a finding that is new, a finding that has come back after being resolved, and a finding that is genuinely gone. Only transitions alert, and a regression is labelled as a regression rather than repeated as a first-time alert — because "this broke again" and "this is broken" call for different responses.
Scan frequency and how many projects you can watch depend on your plan; what a scan reports does not. No plan withholds a finding behind an upgrade prompt.
Check your own deployment
The free scan takes a deployed URL, or a pasted bundle if the site sits behind a login. No account, no stored key, nothing written down about what it finds.
If you would rather look yourself first: open DevTools on your production site, load an authenticated or interactive route rather than just the landing page, and search the loaded assets for eyJ, sk_live_, sk-proj- and AKIA. Not every hit is a problem. You should be able to say what each one is.