AI tools make it easy to ship an app in an afternoon. They also make it easy to ship your secret keys along with it. I wanted to see exactly what a stranger can learn about an app using nothing but a browser, so I built a small leaky app on purpose and looked at it from the outside.
The app is called Leaky Notes, and it lives at https://cheqvo-leaky-demo.vercel.app. Every key in it is fake. They are random strings in the right format, and none of them work. Please use this post to check your own app, not other people's.
What I planted, and why
I planted the mistakes most commonly reported in vibe-coded apps:
-
Supabase secret keys in the front-end JavaScript: an older
service_rolekey and a newersb_secret_...key. Both bypass all database rules. -
An OpenAI key (
sk-proj-...) and a Stripe live secret key (sk_live_...) in the same bundle. This often happens when an AI assistant "just makes the call work" from the browser. -
A public
/.envfile. It gets deployed when the build output folder contains it. -
A public
/.git/HEADand/.git/config. These leak repository details and sometimes the whole history. -
A source map referenced by a
sourceMappingURLcomment at the end of the bundle. It contains the original source code.
I also included keys that look scary but are meant to be public:
-
A Firebase web API key (
AIza...). These are public by design. They identify your project. Your security comes from Firebase Security Rules, not from hiding this key. -
A Supabase
anonkey. This one is also meant to be public, but it is only safe when row-level security (RLS) is turned on for your tables. Without RLS, anyone holding the anon key can read and write your data. -
A Supabase
sb_publishable_...key. Newer Supabase projects (since 2025) usesb_publishable_...andsb_secret_...keys instead ofanonandservice_role. The publishable key plays the same role as the anon key: public by design, safe only with RLS. -
A Stripe publishable key (
pk_live_...). Stripepk_live_andpk_test_keys are made for the browser. Onlysk_(and restrictedrk_) keys are secret.
The ones to worry about are secret keys: sk_live_/rk_live_ (Stripe), sk- (OpenAI and others), service_role and sb_secret_ (Supabase).
How each one is found from the outside
You need no special tools for any of this. Try each step on your own deployed app.
Keys in the JavaScript. Open your site, right-click, and choose "View page source." Find the <script src="..."> tags and open those files. Or open DevTools, go to the Sources tab, and press Ctrl+F (Cmd+F on a Mac) to search across all loaded files. Search for sk_live, sk-, sb_secret_, service_role, eyJ, and secret. In Leaky Notes, all of them sit in plain text in /assets/index-a1b2c3.js.
Newer Supabase projects (since 2025) use sb_publishable_... and sb_secret_... keys. If you find sb_secret_ in the browser, it is a master key. Older projects use anon and service_role keys, which are JWTs (they start with eyJ). For those older keys only, paste one into any JWT decoder and read the role field. If it says service_role, the key is a master key.
The .env file. Visit https://your-app.com/.env in your browser. If you see your variables instead of a 404 page, everyone else can see them too.
The .git folder. Visit /.git/HEAD. If you see ref: refs/heads/main, your Git folder is public. Then check /.git/config, which often shows your remote URL. With enough files exposed, people can rebuild your repository, including secrets you deleted in later commits.
Source maps. Scroll to the very last line of your JavaScript bundle. Look for a comment like //# sourceMappingURL=.... Open that URL. In my demo, the .map path returned 403, but the comment points to /sm/index-a1b2c3.sourcemap.json, so following the comment still finds it. The file includes a sourcesContent field with my original App.jsx. Always follow the comment instead of guessing file names.
What someone could do with each one
-
Supabase
service_roleorsb_secret_key: read, change, or delete every row in your database, ignoring all RLS policies. - OpenAI key: run requests on your account until your bill or your limit runs out.
- Stripe live secret key: read customer and payment data, issue refunds, and create charges through your account.
-
.envfile: all of the above at once, plus anything else you stored there. -
.gitfolder: your source code and its history, which may include old keys. - Source maps: your readable source code, comments, and internal API routes. This makes every other bug easier to find.
How to fix each one
1. Rotate first. If a secret key was ever deployed to the public, treat it as stolen. Deleting it from the code is not enough, because copies may already exist. Generate a new key in the provider's dashboard (Supabase, OpenAI, Stripe) and revoke the old one. Do this before anything else. On older Supabase projects, you can't revoke the service_role key on its own, because it shares a JWT secret with the anon key. The cleaner path is to switch to the new sb_secret_ and sb_publishable_ keys, disable the legacy keys, and then revoke the legacy JWT secret.
2. Move secrets to the server. Secret keys belong only in server-side environment variables. Be careful with prefixes: variables that start with NEXT_PUBLIC_, VITE_, or REACT_APP_ are built into the browser bundle on purpose. Never put a secret in one of those.
3. Use a small serverless function as a proxy. Instead of calling OpenAI or Stripe from the browser, have the browser call your own route. That route reads the key from the environment and calls the provider.
// api/chat.js (a Vercel serverless function)
export default async function handler(req, res) {
if (req.method !== "POST") return res.status(405).end();
// Check that the user is logged in and rate-limit here.
const r = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-4o-mini",
messages: [{ role: "user", content: String(req.body?.prompt ?? "") }],
}),
});
res.status(r.status).json(await r.json());
}
Add authentication and rate limits to that route. Otherwise you have only moved the open door.
4. Block dotfiles. Make sure .env and .git never end up in your build output folder. Add .env* to .gitignore. Check that your build script does not copy them. As a second layer, configure your host or server to deny any path that starts with a dot.
5. Turn off production source maps, or upload them privately to your error tracker instead of serving them. In Vite, set build.sourcemap: false. In Next.js, productionBrowserSourceMaps is off by default, so leave it off. Then check the last line of your bundle again.
6. Turn on Supabase RLS. Enable row-level security on every table, and write policies that only allow each user to reach their own rows. The anon or sb_publishable_ key stays in the front end, and that is fine once RLS is doing its job. The service_role and sb_secret_ keys must only ever live on the server.
Copy-paste checklist
[ ] Searched my live JS bundles for sk_live, sk-, sb_secret_, service_role, secret
[ ] No sb_secret_ key in the front end (sb_publishable_ is fine with RLS)
[ ] Older projects: decoded any Supabase JWT in the front end; role is "anon", not "service_role"
[ ] Rotated every secret key that was ever public
[ ] No secrets in NEXT_PUBLIC_ / VITE_ / REACT_APP_ variables
[ ] OpenAI, Stripe, and other secret calls go through my own server route
[ ] That route checks auth and has a rate limit
[ ] /.env returns 404
[ ] /.git/HEAD and /.git/config return 404
[ ] No sourceMappingURL comment in production bundles (or the URL returns 404)
[ ] RLS enabled on every Supabase table, with real policies
[ ] Firebase Security Rules reviewed (the AIza key itself is fine)
The limits of checking from the outside
Everything above can be seen by anyone with a browser. That is exactly why it is worth checking. But an outside check has real blind spots.
It cannot see your database rules. It can tell you that your app uses Supabase or Firebase, but it cannot tell you whether RLS or Security Rules are set up correctly. It cannot read your server-side code. It will not find SQL injection, broken access control between users, or logic bugs in your API routes. A clean result from the outside means only that these specific leaks were not visible. It does not mean your app is safe.
A note on how I check this
I built a small tool, Cheqvo (https://cheqvo.com/?ref=devto), that runs these same outside-in checks. On Leaky Notes it found the four secret keys, the public .env file, the exposed .git folder and the source map. It left the publishable keys alone. For Supabase and Firebase it said it couldn't check the database rules from outside, which is true of any outside check. The checklist above works fine by hand too.
I wrote this with help from an AI assistant and checked every claim against my demo.
Top comments (1)
The distinction you draw between leaked secrets and public-by-design keys is the part most checklists blur, and it changes the triage order: a Firebase AIza key or a Supabase anon key is a capability question (what do the rules allow), while a service_role key or an sk_live_ key is an existence question (what can be read or moved regardless of rules). Sorting findings by capability instead of by count is what stops the report from becoming noise.
Two places I would add to the scan list, because neither shows up in a repo secret scanner: the build output and the artefacts. A .env that lands in the deployed folder and a sourceMappingURL that ships a readable copy of the source are both invisible to a pre-commit hook, since the leak is created by the build, not by the commit. The only check that catches them runs against the artefact that actually goes out.
And the format-valid-but-dead key choice is honest but has a cost: most scanners match on format, so a demo full of fake keys produces hits that cannot be distinguished from live ones without testing them - which is exactly the step teams are not allowed to do. Did you end up recommending an inventory of issued-but-unused keys as the thing to diff against, or is the practical answer to fail the build on any secret-shaped string in the bundle?