Secrets, env vars and the committed API key
Why a leaked API key isn't fixed by deleting it from the latest commit, how .env files actually protect you, and what to do the moment a secret leaks.

Someone on the team pastes a database URL into app.ts to get local dev running fast, commits it, means to remove it later, and pushes. That single commit is now permanent. Not "hard to find," permanent, sitting in the repository's history for as long as the repository exists, whether or not anyone ever deletes the line.
This lesson is about that gap between "I removed the secret from the file" and "the secret is no longer exposed," and it's a bigger gap than most people expect.
Git never actually deletes anything you commit
Every commit is a snapshot, and git log keeps every snapshot forever unless you deliberately rewrite history. Delete a line, commit the deletion, and the file looks clean. The secret is still sitting in an earlier commit, one git log -p away from anyone with clone access:
# find it even after it's been "removed"
git log -p --all -S "sk_live_" -- .-S searches the whole history for commits that added or removed a string, not just the current tree. If an API key ever touched a tracked file, this finds it, and so will anyone else who thinks to look. Public repos get scraped for exactly this pattern constantly. GitHub's own secret scanning flags known key formats automatically and notifies the provider, which is a good thing, but it also means a leaked key gets found fast either way. Better it's your scan than someone else's.
Rotate first, clean up second
If a real secret lands in a commit, the fix is never "delete the line and commit again." That leaves the key live in history and live in production simultaneously. Rotate the credential at the provider first, so the leaked value stops working immediately, then worry about scrubbing history.
What actually protects a secret
.env files work because of what's in .gitignore, not because of anything about the .env format itself:
.env
.env.local
.env.*.localGit simply never tracks the file, so it never becomes part of a commit, and there's nothing in history to leak. The pattern that ships alongside it is .env.example, a template with the variable names and no real values, committed on purpose so a new teammate knows what to fill in:
DATABASE_URL=
ARGON2_SECRET=
SESSION_SECRET=DATABASE_URL=postgres://user:realpassword@db.internal:5432/linkstash
ARGON2_SECRET=a-real-random-value-here
SESSION_SECRET=another-real-random-valueThe code reads from process.env either way, so the application logic never changes based on where the values came from:
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) throw new Error("DATABASE_URL is not set");This works because a .env file is a local convenience, not a deployment mechanism. In production, those same variable names get set through your host's own secret store, not by shipping a .env file to a server.
Quick check
Why does adding .env to .gitignore actually prevent a secret leak, rather than just hiding the file from view?
Config that isn't secret still isn't free to get wrong
Not every environment variable is a credential, but the split matters. A public flag, FEATURE_NEW_DASHBOARD=true, is fine to commit. A signing secret used to trust Authorization headers is not. Treat every value in your config the same way you'd treat a piece of user input in the SQL injection lesson: ask what it's actually for before deciding it's safe to hardcode.
One place this bites specifically: build tooling that inlines environment variables into client-side JavaScript bundles, common in frontend frameworks that expose anything prefixed NEXT_PUBLIC_ or VITE_ to the browser by design. That prefix is a contract, not a suggestion. A database URL or a signing secret with that prefix on it ships straight into every visitor's browser dev tools, readable by anyone who opens the page, no leaked commit required.
If a secret already leaked
The order matters here the same way it does anywhere else in incident response:
- Rotate the credential at its source first. A new API key, a new database password, a new signing secret. The leaked value has to stop working before anything else, because it's already out.
- Check what it was used for. A database password, was it scoped to one service or shared across several? A signing secret, does rotating it invalidate every existing session, and is that acceptable right now or does it need a controlled rollout?
- Only then, clean history if it matters. Tools like
git filter-repocan rewrite history to remove a file's past contents, but a public repo may already have been cloned or mirrored elsewhere. Rewriting history helps your own repo. It doesn't retroactively un-leak a key that's already been copied.
Cheap prevention beats expensive cleanup
A pre-commit hook running a secret scanner (gitleaks, trufflehog, GitHub's built-in push protection) catches most of these before they ever leave your machine. It's a few minutes of setup against a rotation-and-cleanup process that can eat an afternoon.
The takeaway
A secret in a commit isn't a formatting mistake you fix by deleting a line. It's a credential that's already out, and the only real fix is making it stop being valid. Keep secrets out of tracked files in the first place, with .gitignore and a .env.example template doing the actual work, and the rotation scenario above never has to happen at all.
Next: supply chain attacks, where the leak doesn't come from your own commit. It comes from a package you trusted.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


