.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
DockerfilewithCOPY . .that copies the.envalongside the build, into an image that also serves static files. - A build that copies the project into a static output directory, or a
.envthat was dropped intopublic/orstatic/because that was where the other config lived. - A deploy over
rsyncor 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.