Securealy
Guide · Secrets · Rotation

My API key leaked from my app. What now?

Rotate it now, before any code change. Create the new credential at the provider, set it everywhere the old one lived, deploy, then revoke the old one and prove it is dead with one request. Then find every copy, including git history and build output, and block the next one with a scanner.

What do I do in the first ten minutes?

Create the replacement first, so there is never a moment with zero working keys. Set it in every environment, deploy, verify with a single request, then revoke the old key at the provider and send one request with the old value to see the 401. A found secret is not a dead secret until that step.

Order is the whole method. Two live keys for ten minutes is fine; zero is an outage. Per provider: Stripe, Developers, API keys, roll the key, which offers a grace window; Supabase, Project Settings, API, and prefer the newer sb_secret_ format, which revokes individually, because regenerating the legacy JWT secret invalidates both legacy keys and signs every user out; Resend and most others, create a new key, then delete the old one.

Set the new value by pasting into the hosting dashboard, or with printf, never echo:

printf %s 'NEW_VALUE' | vercel env add NAME production

echo appends a newline, the SDK puts it in the Authorization header, and the error reads like a network failure. Then deploy, wait for Ready, and make one real request. Then revoke. Then prove it:

curl -s -o /dev/null -w '%{http_code}\n' https://api.PROVIDER/... -H "Authorization: Bearer OLD_VALUE"   # 401 or 403

If the leaked key was a Supabase secret key or a database password, treat every table as read: it bypasses row level security and every gate. If it was a Stripe secret key, review the live charges and refunds for the window it was exposed. secrets-in-bundle and exposed-env-files are the checks that report this class; both are launch blockers.

Where else did the key live?

Search by value, not by name. The same secret often sits under three variable names across projects, in a backup of the env file, in a chat transcript, in a build output directory and in a source map. Search every repository and env store for the first eight characters and list each hit before you rotate.

grep -rn "FIRST8CHARS" ~/projects -l
grep -rl "FIRST8CHARS" .next/static dist public build 2>/dev/null
git log -p --all -S "FIRST8CHARS" --oneline | head

Names mislead. The Fix Kit's own audit found a live database password in sixteen publicly served script files, four days after a check that searched for connection-string URLs had reported nothing, because the password sat on its own PGPASSWORD= line. A later sweep found a published password under a variable named CORRECT_PASSWORD. Search for the shape of a secret, with a scanner, not for the secrets you happen to remember:

gitleaks detect --source . --log-opts="--all" --redact ; echo "exit $?"

Build output is its own copy. A variable prefixed VITE_ or NEXT_PUBLIC_ is inlined into the bundle at build time, so the secret is in the browser even after the source file is fixed, until the next build replaces the deployed files. source-maps matters here too: a reachable map restores the original source and every inlined constant. And check the places that are not code: the env file served by URL (exposed-env-files), the .git directory served by URL (exposed-git), and the hosting environment, where vercel-env-hygiene checks for secrets under public prefixes.

Does deleting it from the repo fix it?

No. Deleting the file in a later commit leaves the value in every earlier commit, in every clone and fork, in the host's build cache and in cached views on the hosting service. GitHub's own guidance starts with revoking the secret; rewriting history is a second step that needs every collaborator to re-clone.

GitHub's page on removing sensitive data puts rotation first: if the sensitive data you need to remove is a secret, then as a first step you need to revoke and/or rotate that secret. It also lists where a rewritten commit still lives: In any clones or forks of your repository, Directly via their SHA-1 hashes in cached views on GitHub, Through any pull requests that reference them. A history rewrite with git filter-repo is optional after rotation, and only after; it is housekeeping, not a fix.

The common way a secret reaches history is a backup. One of Kenny Hyder's own repositories had the ignore rule .env*.local; someone saved .env.local.bak-2026-09-07, the pattern did not match, and a file holding the hosting token, the GitHub token, the database management token and the email key was committed and pushed with no warning. The rule has to cover the family, and a check has to prove it:

Keep a placeholder-only .env.example tracked with a negation line if you want one.

cat >> .gitignore <<'G'
.env
.env.*
*.bak
*.bak-*
G
git check-ignore -v .env.local .env.local.bak-2026-01-01 .env.production
git ls-files | grep -E '\.env|\.bak'   # expect nothing

gitignore-env-coverage runs exactly that on your machine, and repo-secrets scans the working tree, the build output and every commit for value-shaped secrets. Both are local: nothing leaves the machine and no value is printed, only a path and a redacted prefix.

How do I make the next leak impossible?

Move every call that uses a secret to the server and read secrets only from process.env inside a server function. Block the commit: a pre-commit hook that runs a secret scanner on staged files, and push protection at the hosting service. Then scan the live site after each deploy, because the build is where secrets get inlined.

No STRIPE_, SUPABASE_SERVICE or model-provider variable may carry a NEXT_PUBLIC_ or VITE_ prefix; those prefixes mean inline this into the bundle. The browser gets publishable keys only, and the server does the work with the secret after it has checked who is asking. One grep proves the shape of the code:

grep -rnE "(NEXT_PUBLIC|VITE)_[A-Z_]*(SECRET|SERVICE|PRIVATE|PASSWORD|TOKEN)" --include=*.ts --include=*.js --include=*.tsx --include=*.env* . | grep -v node_modules   # expect nothing

Then the hook. gitleaks protect --staged --redact in a pre-commit hook refuses any commit that carries a secret-shaped string; the Fix Kit ships the hook in Module 03 along with a scheduled audit workflow. GitHub's push protection does the same at the remote: it blocks pushes that contain secrets before they reach your repository, free on public repositories and part of the paid Secret Protection product on private ones, so the local hook is the substitute if you skip it.

Last, read the deployed site, not the source, after every deploy. secrets-in-bundle is a passive check; it fetches your home page and first-party scripts and looks for each provider's key format, redacting anything it finds to a prefix. Run it after the rotation deploy and expect a pass. The monitor re-runs it on every deploy so the class cannot return quietly. The full rotation card, per provider, is in Fix Kit Module 03.

Grade it

Confirm the leak is closed

After the rotation deploy, grade the live URL. secrets-in-bundle and source-maps read what the browser gets; a pass means the old key is gone from the pages, and the new one never arrived.

Checks this guide refers to: secrets-in-bundle, source-maps, exposed-env-files, exposed-git, repo-secrets, gitignore-env-coverage, vercel-env-hygiene.