Common .env security mistakes

Credentials rarely leak because someone broke the encryption. They leak because a file got copied somewhere convenient and stayed there. These are the ways .env files actually go wrong, in rough order of how often it happens.

1. Committing it to git

The classic. A .env gets added before .gitignore catches up, and now every clone of the repo contains your production database password. Deleting the file in a later commit does not help, because the old blob is still in history and history is what people clone. This one has its own guide, including what to do when it has already happened and why rotating comes before rewriting history: Should you commit .env to git?

2. Pasting secrets into chat, tickets and AI windows

The most common leak nobody writes about. A colleague needs the staging API key, so it goes in a Slack DM. A bug report needs context, so the whole file goes in the ticket description. A PR body explains the new config by quoting it. Something behaves oddly, so the file gets pasted into an AI chat window with “why isn’t this working?”.

None of those places is a secret store, and all of them keep the value indefinitely. Slack retains and indexes messages and shows them to whoever joins the channel later, and tickets and PR descriptions are readable by anyone with project or repository access. Chat histories sit in an account whose retention policy is not yours. Deleting the message afterwards still leaves the copies that search indexes and notification emails have already made.

Treat “I need to send someone a credential” as its own task with its own tool: a password manager’s sharing feature, a one-time secret link, or let them generate their own key. If a secret has already gone into a channel or a ticket, rotate it. Redacting the message is tidying, not fixing.

3. Production credentials in a local .env to debug something

A support ticket needs reproducing against real data, so the production database URL goes into the local .env for twenty minutes. Twenty minutes becomes a year, because nothing prompts anyone to take it out.

The credential is now on a laptop, in a file that gets backed up, synced and copied into new project folders. And because it works, nobody notices, so the first sign of trouble tends to be a destructive migration rather than a security alert. Use a read-only, short-lived credential for this job, or a restored copy of production. If you do use a live one, rotate it afterwards rather than just deleting the line.

4. Env files inside a build artefact

A .env can escape without anyone committing it. COPY . . in a Dockerfile pulls it into an image layer, and removing it in a later step does not remove it from the layer that added it, so anyone who can pull the image can read it. An npm package with no files field publishes whatever is in the directory. A misconfigured document root serves the file straight over HTTP, which is why automated scanners request /.env on public hosts constantly, without needing any reason to think it is there.

.dockerignore and .npmignore are separate lists from .gitignore, and only one of the three is the one people remember, so add the file to all of them. Pass configuration into containers as environment variables or mounted secrets rather than baking a file in, and point the document root at a public directory below the project root. Then check rather than assume: look inside the built image and at the packed tarball before you ship either.

5. Secrets in CI logs

Build logs are the leakiest place in most setups, because printing things is how people debug pipelines. set -x echoes every command with its arguments expanded, a printenv added during a bad afternoon never gets removed, and a failing build step dumps its config into the output everyone reads.

Log masking helps, but it only covers values the platform knows are secrets, so anything derived, concatenated or base64-encoded passes through in the clear. Logs are kept for weeks and downloadable by everyone with repository read access. Keep secrets out of command lines, keep set -x out of any step that touches credentials, and make failure handlers say which variables are missing rather than printing their values.

6. One credential used everywhere

The same API key is in local, staging and production, in two other projects, and in a function somebody wrote in 2022. Each reuse saved five minutes. Together they make rotation a project with a planning meeting rather than a task, so rotation never happens, and nothing is ever rotated after someone leaves.

That last part is the real cost. Offboarding revokes accounts, and accounts are the easy half: a shared credential on a leaver’s laptop stays valid until somebody volunteers to break something by changing it. One credential per environment per service keeps a rotation small enough to do in an afternoon, and per-user keys, where the provider offers them, mean offboarding actually revokes access.

7. Treating a public-prefixed variable as a secret

NEXT_PUBLIC_STRIPE_KEY sits in the same file as STRIPE_SECRET_KEY, so it feels equally protected. It is not. Anything prefixed NEXT_PUBLIC_ in Next.js, or VITE_ in Vite, is inlined into the client bundle at build time and shipped to the browser, where anyone can read it in view-source.

That is fine for what the prefixes are for: publishable keys, public URLs, feature flags. It becomes a problem when a real secret gets the prefix to make an undefined-variable error go away, which happens often, because adding the prefix does make the error go away. Read the prefix as “this will be public”, and if client code needs a genuinely secret value, put the call on the server instead.

8. Keys with more scope than the job needs

A personal access token with full organisation scope, created so a script could read one file. A cloud key with admin rights, because that was the default. Neither expires, because an expiry means dealing with it again later.

Scope and lifetime decide how bad a leak is, and both get set at creation time, when nobody is thinking about leaks. A read-only, ninety-day token that leaks is an inconvenience. An unexpiring admin key is an incident. Create credentials at the smallest scope that works, set an expiry even where it is optional, and prefer short-lived platform-issued tokens (OIDC in CI, instance roles in cloud) over long-lived keys in files.

9. Handing the file around with no record of who has it

The new developer needs a working .env, so it arrives by AirDrop, or email, or a USB stick. It works, and the file is now on an unknown number of machines.

The bill arrives months later, when a key needs rotating and the honest answer to “who has this?” is “nobody knows”. Email is the worst, because copies sit in the sender’s outbox, the recipient’s inbox and both providers’ backups. Pick one distribution route you can point at: a shared secrets manager, or an encrypted file in the repo with the key shared separately. Both make “who can read the current values” a question you can answer.

10. Backups, duplicates and shell history

.env.bak before a risky change. .env.old after a migration. .env 2 from a Finder copy. A .gitignore that lists .env exactly ignores none of them, so the backup becomes the file that gets committed.

Shell history is the same idea somewhere else. export STRIPE_SECRET_KEY=sk_live_... sits in ~/.zsh_history in plain text, gets synced to a dotfiles repo, and outlives the credential by years. Ignore the pattern rather than the filename (.env* with a negated !.env.example covers the family), delete backups once the risky change has worked, and read one-off values from a password manager’s CLI instead of typing them.

If you think a secret has leaked

Order matters, and most people get it backwards by starting with the cleanup, because cleanup feels productive.

  1. Rotate it. Issue a new credential, deploy it, revoke the old one. This is the only step that removes the exposure. Everything else is bookkeeping.
  2. Check whether it was used. Look at the provider’s audit or access log for requests you cannot account for between the exposure and the revocation.
  3. Clean up. Remove the value from wherever it leaked: git history, the log, the ticket, the image. Do this last, knowing it is hygiene rather than remediation. A secret that was public and is now hidden is still compromised.

Where Dotvault fits

Dotvault is an env file editor rather than a scanner, so it is worth being precise. It flags values that look like credentials: when a value matches a known pattern (Stripe, GitHub, AWS, Slack and similar) or sits under a key name that always holds a secret, and the file is one git can see and has not been told to ignore, that row is marked as an exposed secret. A Warnings filter shows every flagged row at once. Two limits, plainly: it is a warning and not a fix, so it will not untrack the file, edit your .gitignore, or clean a value out of an existing commit. And that check says nothing about a properly ignored .env, because the file is not exposed to git at all. More detail in what “exposed secret” means and Working with git.

For mistake seven there is a second warning, and it is the one that does speak up about a properly ignored file. A NEXT_PUBLIC_ variable in a Next.js project is annotated as client-exposed in the editor, and if the value can only be a secret, a Stripe or Anthropic secret key, a GitHub or Slack token, an AWS access key id, a webhook signing secret, or a key named SECRET, PASSWORD or PRIVATE_KEY, the row is flagged outright. Git does not come into it: .env.local is gitignored exactly as it should be, and the value still gets inlined into the bundle and served to the browser.

The same applies to VITE_, REACT_APP_, GATSBY_, NUXT_PUBLIC_, EXPO_PUBLIC_ and SvelteKit’s and Astro’s PUBLIC_, in a project where that framework has actually been detected. A prefix means nothing on its own, and nothing is bundled in a plain Node project. Publishable keys and public API keys are deliberately left alone, because a warning you can only clear by deleting working code is a warning people learn to scroll past. There is more in what “client-exposed secret” means. It is still a warning rather than a fix: it will not move the call to your server for you.

For “when did this change”, every save writes a local encrypted snapshot, so you can see what a value was last week without the file being in git. That is the practical route to a history for a correctly gitignored file (Snapshots and history).

For mistake nine, Dotvault reads and writes Laravel’s encrypted env format directly, so the encrypted file can live in the repo while the key travels by a route you choose (Working with encrypted env files). To be clear about what is encrypted: that format, and the snapshot history. A plain .env on your disk stays plain text, which is why the rest of this page matters. What the app sends anywhere is listed on the security page.

If you manage env files on a Mac and want those warnings, that history and the encrypted-file support in one place, Dotvault is worth a look.