Guide · Deployment exposure

Is your .env file readable from the internet?

A .env file usually holds every credential your application has: the database password, the payment key, the mail password, the token for whatever else you plugged in. If it ends up next to your pages on a web server, it is a plain text file anyone can request by name.

Nothing links to it, so you will never find it by clicking around your own site. Below: how to check, the second way it leaks that has nothing to do with the file at all, and how to fix both.


Check it in ten seconds

curl -s -o /dev/null -w "%{http_code}\n" https://yourdomain.com/.env

404 or 403 is the answer you want. A 200 is not proof on its own — plenty of sites answer 200 for every address, because a single-page app or a holding page serves the same document whatever you ask for. Look at what came back:

curl -s https://yourdomain.com/.env | head -5

Lines like DATABASE_URL=… or STRIPE_SECRET_KEY=… mean it is genuinely being served. HTML means you are looking at your own homepage and this check is fine.

Worth trying the variants too — deploys often exclude one and forget the rest:

for f in .env .env.local .env.production .env.backup .env.save; do
  echo -n "$f "
  curl -s -o /dev/null -w "%{http_code}\n" "https://yourdomain.com/$f"
done

The version that leaks without the file

This one catches more people than the file does, and it is invisible to every check above.

Modern frameworks let you expose selected environment variables to the browser by giving them a prefix. In Next.js that prefix is NEXT_PUBLIC_; in Vite it is VITE_; older Create React App used REACT_APP_. Any variable carrying the prefix is substituted into your JavaScript at build time and shipped to every visitor. That is the documented purpose of the prefix — it is working exactly as designed.

The problem is that the prefix is a naming convention, not a safety check. Nothing stops this:

NEXT_PUBLIC_STRIPE_SECRET_KEY=sk_live_...
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE=eyJ...

Both build without a warning. Both end up in a file anyone can read. And the .env file itself was never exposed — it did its job perfectly and handed the value to the compiler, which published it.

Which keys are meant to be public, and which are not.

pk_live_ (Stripe publishable) and a Supabase anon key belong in the browser — they are designed for it. sk_live_ and a Supabase service_role key are the opposite, and each looks almost identical to its safe counterpart. That resemblance is the entire trap.

To check your own bundle by hand, open the page, view source, and search the JavaScript for sk_live_, AKIA, or service_role. It is tedious, which is why this is one of the checks Preflight runs for you.


Why this one is worse than most

An exposed .env needs no tooling to exploit. A reachable git directory at least has to be reconstructed with a program; a .env is a text file that opens in a browser.

It also tends to hold everything at once. One file commonly carries the database, the payment processor, the mail sender and the object store — so a single request can hand over your data, your money and the ability to send email as you, together.


How to fix it

Three things, and the order matters. Rotating is what undoes the exposure that already happened; the rest stops it recurring.

1. Rotate every credential the file contains

Do this first, before tidying anything. If the file has been readable you cannot know who fetched it, and removing it does not invalidate anything inside it. Replace the values at each issuer — Stripe, your database, your mail provider — and then continue.

2. Stop serving dotfiles

nginx:

location ~ /\.(?!well-known) {
    deny all;
    return 404;
}

The negative lookahead is load-bearing. A regex location outranks a prefix location in nginx, so a bare location ~ /\. also blocks /.well-known/acme-challenge/ and breaks certificate renewal — silently, and up to ninety days later.

Apache:

<FilesMatch "^\.">
    Require all denied
</FilesMatch>

Caddy:

@dotfiles path_regexp /\.(?!well-known)
respond @dotfiles 404

3. Stop deploying it at all

Blocking the path is the second line, not the first. The file should not be on the web server to begin with — exclude it from whatever copies your project up (.dockerignore, rsync --exclude='.env', your host's ignore file), and supply configuration through the platform's environment settings rather than a file in the document root.

4. And for the prefix version

Move the value out of the public variable entirely. A secret cannot be used safely in a browser — anything the browser can read, a visitor can read. The usual shape is to keep the key server-side and have the browser call your own endpoint, which uses the key without ever sending it. Then rotate the key, because it has been published in every build since you added it.


Check the rest of the surface

Files that were never meant to be served are one family of a larger problem: things that are live, costing you something, and invisible from a browser. A reachable .git directory is the same shape. So is a debug page printing your configuration, a source map publishing your original code, and a noindex left over from staging that keeps you out of Google entirely.

Related: what we found in 12 AI-generated codebases — including a key that was committed, "removed", and still sitting in the history.

Preflight checks a site's public surface for 42 of them, says what it observed, what that does not prove, and the specific change that fixes it. The scan is free and needs no account.

Run a free scan →

This page describes how a misconfiguration behaves and how to correct it on your own site. Running these checks against a domain you do not control is your responsibility, not ours.