Exposed secrets & API keys
What it is
A secret is anything that proves your app is allowed to do something: an API key, a database password, a token-signing secret, a cloud access key. A secret is exposed when it ends up somewhere other people can read it. That means your source code, your git history, a public JavaScript bundle, a log file or a screenshot.
Think of it as leaving your house key under the doormat with your address written on it. The lock still works perfectly; it just opens for whoever found the key.
Why it matters
Most API keys aren't tied to a person. Whoever holds one can use it with the same power your app has. Depending on the key, that means reading your whole database, sending email as your company, charging cards through your payment account or spinning up servers on your cloud bill.
Leaked keys are found quickly. Public repositories are scanned continuously by automated tools, and git keeps everything ever committed, so a key that was "deleted in the next commit" is still one git log away.
A well-documented example: according to the U.S. Federal Trade Commission, attackers in 2016 used an access key that Uber engineers had stored in a code repository to download data on about 57 million riders and drivers from cloud storage.
How it happens
Secrets usually leak in one of four ways:
- Pasted into code to get something working, then committed.
- Committed in a
.envfile that wasn't in.gitignoreyet. - Shipped to the browser through a public environment variable (
NEXT_PUBLIC_,VITE_,REACT_APP_,EXPO_PUBLIC_) that ends up in the JavaScript bundle. - Left in git history after being removed from the current code.
// lib/stripe.tsimport Stripe from "stripe"; // A live secret key, committed to the repoexport const stripe = new Stripe("sk_live_51H...redacted...");The browser case deserves its own warning. A variable prefixed with NEXT_PUBLIC_ or VITE_ is copied into the JavaScript your users download. Supabase's service_role key is a common casualty: it bypasses Row Level Security, so putting it in a public variable gives every visitor full read and write access to your database.
How it's abused
At a conceptual level, the attacker's path is short:
- Find the secret: search public code, read a site's JavaScript bundles, or browse a repository's history.
- Identify what it's for from its prefix (
sk_live_for Stripe,AKIAfor AWS) or the code around it. - Use it directly against the provider's API, with all the permissions the key carries.
- Move from there: read data, create resources, or find more credentials stored in the systems the key opened.
None of this requires breaking into your app. The key is the access.
How to fix it
First, rotate. If a secret was ever committed or shipped to a browser, treat it as known. Create a new key in the provider's dashboard, deploy it, then revoke the old one. Removing it from the code doesn't revoke it.
Then move secrets out of code and keep server-only secrets on the server.
import "server-only";import Stripe from "stripe"; // Set STRIPE_SECRET_KEY in your host's secret settings (never NEXT_PUBLIC_)export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);Why this works: the value lives in your hosting provider's secret store, so it never enters git. The server-only import in the TypeScript example makes the build fail if that file is ever imported into browser code.
A few more habits help:
- Keep
.envfiles out of git from the first commit, and commit a.env.examplewith placeholder values only. - Never put a secret in a public-prefixed variable. Only values that are safe for every visitor to read belong there, such as a Supabase anon key or a Stripe publishable key.
- Clean history only after rotating. Rewriting git history doesn't help with copies that already exist, so rotation always comes first. GitHub has a guide to removing sensitive data from a repository.
- Use the smallest key that works. Restricted keys with only the permissions a job needs limit the damage if one leaks.
Common mistakes
- Deleting the key instead of rotating it. The old key keeps working until you revoke it in the provider's dashboard.
- Moving a secret into a public variable. Renaming
STRIPE_SECRET_KEYtoNEXT_PUBLIC_STRIPE_SECRET_KEYto make an error go away ships it to every browser. - Base64-encoding a secret. Encoding isn't encryption; anyone can decode it.
- Committing a "temporary"
.env. Add.env*to.gitignorebefore the first commit, and keep only.env.examplein git.
Testing your fix
Search your repository, including history, for the old key's prefix; it should appear nowhere in current files. Build your frontend and search the output folder (.next/static, dist) for anything that looks like a secret key. Finally, confirm the old key is revoked: a request made with it should be rejected by the provider.
How SetRepo detects it
Secrets checks run on every Quick and Deep scan.
- Code and git history: gitleaks scans your files and, when the repository is scanned from git, its recent commit history (up to a set number of commits). A key deleted from the code but still in history is reported as such. Zip uploads have no history, so only the files are scanned.
- Supabase service_role exposure: SetRepo flags the service_role key when it's reachable from client code. That covers a public-prefixed variable holding it, the key referenced in browser or mobile code, or a service_role JWT outside server code. With a live URL, it also checks your public JavaScript for the key.
- Hardcoded signing secrets: token-signing secrets written into code, or used as a fallback when an environment variable is missing.
- Template env files: values in files like
.env.examplethat look real rather than placeholders.
Secret values are redacted before anything is stored. Your report shows the file, the line and the kind of key, never the key itself.
Checklist
- No API keys, passwords or signing secrets in source code.
.envfiles are in.gitignore;.env.exampleholds placeholders only.- No secret uses a
NEXT_PUBLIC_,VITE_,REACT_APP_or similar public prefix. - The Supabase service_role key is only used in server code.
- Any key that was ever committed has been rotated, and the old one revoked.
- Keys have the narrowest permissions that work.
- Secrets live in your host's secret store or a secrets manager.
FAQ
I deleted the key in the next commit. Am I safe?
No. Git keeps every version, and anyone with access to the repository can read the old commit. Rotate the key; cleaning history is optional after that.
Is my Supabase anon key a secret?
No. It's designed to be public, and it's safe as long as Row Level Security protects your tables. The service_role key is the secret one.
My repository is private. Does this still matter?
Yes. Private repositories get shared with contractors, cloned to laptops, connected to third-party tools and occasionally made public by mistake. Treat any committed secret as exposed.
What should I do in the first hour after a key leaks?
Rotate it: create a new key, deploy it, revoke the old one. Then check the provider's logs for activity you don't recognize during the time the key was exposed, and look for anything created with it, such as new users, API keys or servers.