Writeup
Six places a secret survives .gitignore
Ignoring .env stops one file being committed. It does not stop the value reaching the client bundle, the test fixtures, or the history you already pushed.
In short
- .gitignore prevents a file from being added. It does nothing about a value already in history or pasted somewhere else.
- In Next.js, prefixing a variable NEXT_PUBLIC_ ships it to every visitor in the JavaScript bundle.
- Rotate first, then remove. A secret in a pushed commit is compromised even after the file is deleted.
The vulnerability class
A secret is exposed when it can be read by someone who should not have it. Committing
.env is the version everyone knows, so it is the version most projects have already
defended against — and that defence is often mistaken for the whole problem.
Everything below is a place a real credential ends up with .env correctly ignored.
Where it hides in modern stacks
1. The history you already pushed
.gitignore applies to files git is not yet tracking. It has no effect on a file
committed before the rule existed.
git log --all --full-history -- .env # was it ever committed?
git log -p --all -S 'SECRET_KEY' | head -40 # when did this string appear?
Deleting the file in a later commit removes it from the working tree, not from history. Anyone with the repository still has it.
Watch out. If a key was pushed, treat it as compromised and rotate it. Rewriting history is cleanup, not remediation — you cannot know who cloned in between, and on a public repository you should assume it was scraped within minutes.
2. The client bundle
Next.js decides what is public by prefix. This is a deliberate, documented behaviour and it is still one of the easiest ways to leak a key:
// Anything NEXT_PUBLIC_* is inlined into JavaScript served to every visitor.
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!)
// No prefix: stays server-side. Reading it in a client component now fails loudly.
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
Grep the built output rather than trusting the prefix rule from memory:
grep -rn "sk_live\|-----BEGIN\|AKIA" .next/static 2>/dev/null
3. .env.example
The file that exists to be committed. It is supposed to carry keys with blank values, and it drifts — someone fills in a working value to debug, and commits it with everything else.
4. Test fixtures and seeds
Fixtures need something that parses. A real token is the fastest way to make a test pass, and fixtures are rarely re-read once they are green.
5. CI configuration
Workflow files live in the repository. A value pasted into env: to unblock a failing
pipeline is committed like any other line of YAML.
env:
DATABASE_URL: postgres://user:hunter2@db.example.com:5432/app
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
6. Everything that is not the repository
The screenshot in the issue. The stack trace with the connection string in it. The curl
in the README written with a live token. The log line that prints the whole config object
on boot.
The fix
Two habits, in this order.
Rotate anything that was ever pushed. This is the only step that changes the security state. Everything else is hygiene.
Move the check before the commit, not after. A pre-commit hook that scans staged
changes catches the paste at the moment it happens, which is the only moment it is cheap.
/tools has one.
What still does not protect you
A scanner does not find secrets it has no pattern for. Provider-issued keys have recognisable shapes. A database password someone chose does not.
Entropy checks cut both ways. Raise the threshold and you miss hunter2; lower it and
every hash and minified bundle is a finding, which trains people to skip the warning.
And none of this covers runtime. A secret injected correctly at deploy time still leaks if the application prints it, returns it in an error, or sends it to a third-party logger.
