All posts
VibeZero Team8 min read

.env file exposure, explained

A .env file leaks two ways: committed to a repo, or served by your own web server. One extortion campaign harvested 90,000 variables from the second route.

  • security
  • vibe coding
  • engineering

The .env file is the one place in a project where every secret is guaranteed to be, in plain text, correctly labeled, in a format a script can parse. It exists precisely so that credentials stay out of code. That works, and it concentrates the entire blast radius of a project into a single file whose name everyone knows.

There are two ways it gets out, and they have almost nothing in common. One is a git problem. The other is a web server problem, and it is the one nobody on a small team is checking.

Route one: it is in the repository

The ordinary path. .env was never added to .gitignore, or it was added after the first commit, or somebody committed .env.production because the name did not match the ignore pattern.

The defense is three lines and belongs in the project before the first secret does:

# .gitignore
.env
.env.*
!.env.example

Next.js ships this by default and its documentation says why in a warning: “The default create-next-app template ensures all .env files are added to your .gitignore. You almost never want to commit these files to your repository.” Vite’s guidance is the same from the other side: .env.*.local files “are local-only and can contain sensitive variables,” so add *.local to .gitignore.

The important thing about this route is that fixing it in a new commit fixes nothing, because git keeps every version of every file it has ever seen. That is git history secrets, and it is the reason a project that “removed the .env months ago” is often still leaking.

Route two: your web server is serving it

This is the interesting one, and the shape of the mistake is always the same. A .env file ends up inside a directory that a web server publishes, and the server, which has no idea what the file means, serves it like any other static asset. Someone requests https://yourapp.com/.env and reads it in a browser.

It happens through a small number of very ordinary moves:

  • The project root is the document root, which is the default in a lot of shared hosting and in older PHP layouts.
  • A Dockerfile with COPY . . that copies the .env alongside the build, into an image that also serves static files.
  • A build that copies the project into a static output directory, or a .env that was dropped into public/ or static/ because that was where the other config lived.
  • A deploy over rsync or FTP that pushes the working directory as-is.

Nothing in that list is exotic and none of it produces a visible symptom. The app works. The site loads. The file is simply also there.

It is worth saying which projects this does and does not reach. A managed preview host that only serves a build directory will not expose the .env this way, which covers most of what a Lovable or Replit project serves before you move it. The moment you export that project to your own VPS, container or shared host, and point a web server at a directory you chose, this route opens and nothing warns you that it did.

What it is worth to an attacker, measured

This is not a theoretical risk with a hypothetical attacker, and there is a primary source with numbers.

In August 2024, Palo Alto’s Unit 42 published an analysis of a large-scale extortion campaign built entirely on this one mistake. The campaign “targeted 110,000 domains resulting in over 90,000 unique variables in the .env files.” Of those, 7,000 were “access keys directly associated with various cloud services” and 1,515 belonged to social media platforms.

What they did with them is the part worth reading twice. Using exposed AWS IAM access keys, the attackers created an IAM role named lambda-ex and used AttachRolePolicy to give it AdministratorAccess, then deployed Lambda functions inside the victims’ own accounts to scan “more than 230 million unique targets” for more exposed environment files. Victim infrastructure became the scanning infrastructure. They exfiltrated S3 buckets, deleted the contents, and left a ransom note in the empty bucket.

Unit 42’s own list of root causes is three items long, and the first one is this post: exposing environment variables, using long-lived credentials, and the absence of least privilege. The second and third are what turned a leaked file into an account takeover, which is worth remembering when you decide how much authority to give the key you are about to rotate to.

The framework-specific detonation

Some .env values are worse than the sum of their parts, because a framework uses them as the root of trust rather than as a password.

Laravel’s APP_KEY is the clearest example. GitGuardian and Synacktiv published research in July 2025 after extracting roughly 260,000 APP_KEY values from public GitHub between 2018 and May 2025. The key is a 32-byte symmetric key used to encrypt cookies, session data and reset tokens, and Laravel’s decrypt() deserializes what it decrypts. An attacker holding the key can craft an encrypted payload that becomes remote code execution on decrypt. The researchers confirmed more than 600 applications reachable this way.

They also found that 35 percent of APP_KEY exposures came with additional critical credentials in the same file, which is the property that makes this class compound: the file is not one secret, it is all of them, and whoever gets it gets your database URL and your mail credentials and your object storage in the same request.

The same logic applies to a Supabase project. A .env containing SUPABASE_SERVICE_ROLE_KEY is not a leaked password, it is a key that bypasses every RLS policy you wrote.

Blocking it at the server

The fix is a deny rule for dotfiles, and it belongs in the server config rather than in your application, because the application never sees these requests.

# nginx. The ^~ block must come first: a prefix match with ^~ beats a regex
# location, which is what keeps the deny rule below from eating .well-known.
location ^~ /.well-known/ {
    # ACME challenges and security.txt live here. Serve them normally.
}

location ~ /\. {
    return 404;
}
# Apache 2.4. FilesMatch only sees the basename, so it would miss
# /.git/config entirely. Match the path instead.
RedirectMatch 404 "/\.(?!well-known/)"
# Caddy. path matchers do not do negative lookahead, so use path_regexp.
@dotfiles path_regexp /\.(?!well-known/)
respond @dotfiles 404

Two details in there are the whole reason this section is longer than one line.

The .well-known exemption is not optional. A naive “deny everything starting with a dot” also denies /.well-known/acme-challenge/, which is how Let’s Encrypt proves you control the domain. The rule works, the site stays up, and then in sixty days your certificate fails to renew and the site goes down for a reason nobody connects to a change made two months earlier. It also blocks /.well-known/security.txt, which is the file a researcher looks for when they want to tell you about something quietly.

Return 404, not 403. A 403 confirms the file is there. And deny the whole dotfile class rather than .env by name, because the next thing somebody requests is /.git/config, and a readable .git directory hands over your entire source history rather than one file.

Two structural changes matter more than the deny rule, though, because they remove the file from the server entirely:

Keep the document root below the project root. If the server publishes public/ and the .env lives one level above it, no rule is required for this to be safe. This is the arrangement Laravel, Symfony and most modern frameworks already assume, and it is what people undo when they point a virtual host at the wrong directory.

Do not ship the file into the image. A .dockerignore with .env in it costs one line, and environment variables injected by the platform at runtime are not files at all, so they cannot be served by accident. If your Dockerfile starts with COPY . ., look at what that copied.

What we check, and the line we will not cross

Here we have to be straight about something, because the honest answer is more useful than the impressive one.

Our scanner does not request /.env on your site, and it never will. The runtime engine that reads your deployed application is passive by design: it reads the entry page, follows redirects, reads the same-origin scripts that page asked for, and reads a frozen four-path allowlist of files published for robots (robots.txt, sitemap.xml, llms.txt, security.txt). Its own module documentation draws the boundary in the same words we would use to a customer: guessing at a path the site did not publish, specifically naming /.env and /.git/config, is active testing. It needs proof that you own the target, and that proof is not something our control plane implements yet. A test in the worker asserts that allowlist by equality, so widening it requires a reviewer rather than a commit.

That is why the curl in the callout above is yours to run and not ours. On your own host it is one request and takes half a minute.

What we do cover is the other route and the consequences. Gitleaks and TruffleHog read the repository for the committed copy, which is hardcoded credentials by another name. Trivy reads your Dockerfile and compose files in the config layer, which is where the COPY . . lives. And our free exposed API key scanner reads the JavaScript your site serves, which catches the third route: a build tool that read the .env and inlined its values into the bundle, described in client-side env prefixes.

You cannot triage a file

A finding normally arrives with questions attached. How bad is it, what does it reach, which part should we look at first. An exposed .env refuses all three. It is sorted, labeled, formatted for machine consumption, and it names the services it opens, so there is no interesting subset to start with and no way to learn which lines were read before you got there.

So the response is not triage, it is inventory. Every value in that file is compromised, each one gets rotated at its own provider, and that includes the boring ones: the SMTP password nobody thinks about is how the phishing mail goes out with your domain on it. The campaign in the section above collected 90,000 variables across 110,000 domains, and not one of those owners could tell you which of their lines had been taken either.

Then, once the file is worthless, go find out why the server was willing to serve it, because that answer applies to every other dotfile in the directory.

ShareXLinkedIn