Your .env is Public. Here's How to Fix It. | Data Hogo
.env file exposed in production? curl -I https://yourapp.com/.env tells you in seconds. Nginx fix, git removal, and a rotation checklist for every secret.
Rod
Founder & Developer
If you're not sure whether your .env is publicly accessible, run this first:
curl -I https://<YOUR_DOMAIN>/.envIf you get a 200 OK back, your secrets are live right now. Keep reading — the fix is here.
This isn't the same problem as a single API key committed to git. This is the whole file. Database passwords, JWT secrets, Stripe keys, email credentials — everything in one request. Per OWASP A02:2021 Cryptographic Failures, storing secrets in publicly accessible files is one of the most direct paths to a full application compromise.
There are two ways this happens: your web server is serving the file as a static asset, or the file is tracked in your git repo and visible there. You may have both problems at once. This guide covers the env file exposed production fix for both — and a complete rotation checklist for when the entire file is out.
Step 0: Check if Your .env is Accessible Right Now
Run the diagnostic. The response code tells you exactly what you're dealing with.
curl -I https://<YOUR_DOMAIN>/.env| Response | What it means | What to do |
|---|---|---|
200 OK |
The file is being served. Your secrets are exposed right now. | Go to Track A immediately. |
403 Forbidden |
The file exists on the server but access is denied. The file is still there — this is partially mitigated, not fixed. | Go to Track A for the config fix and file removal. |
404 Not Found |
The server isn't serving the file at this path. Web server exposure risk is low. | Check Track B for git exposure. |
A
403is often misread as safe. It isn't. The file exists on your filesystem. A misconfiguration change, a server reload, or a different request path could expose it. Remove the file from the server, don't just block it.
Also check the variant paths — attackers try all of these:
# Check all common .env variants in one pass
for f in .env .env.local .env.production .env.development .env.backup .env.example; do
echo -n "$f: "; curl -s -o /dev/null -w "%{http_code}" "https://<YOUR_DOMAIN>/$f"; echo
doneAny 200 is a problem. A 200 on .env.backup is just as bad as a 200 on .env.
Rather than checking paths one by one, scan all 13 common .env paths instantly with our free .env Leak Scanner — paste your URL and see which files are exposed.
Track A: Your .env is Being Served by Your Web Server
Why this happens
Static file servers serve everything in the project directory unless told not to. If your .env file is in the document root — because it was committed to git and deployed, manually uploaded, or present from a build step — most server configurations will serve it as plain text.
Nginx's try_files directive and Apache's default document root behavior are the most common causes. Managed platforms like Vercel, Railway, and Render protect against this at the platform level. Self-hosted deployments on Nginx or Apache are not protected by default.
Immediate fix by platform
| Platform | Status | Fix |
|---|---|---|
| Vercel | Protected by default | Vercel's runtime excludes .env files from serving. If you got a 200 on a Vercel deployment, the file is exposed through git — see Track B. Use Vercel environment variables instead. |
| Railway | Protected by default | Railway injects environment variables from its dashboard. .env files aren't deployed unless you committed them. |
| Render | Protected by default | Same as Railway — variables are injected at runtime, not deployed as files. |
| Nginx (self-hosted) | Requires config | Add the deny block below to your server config. |
| Apache (self-hosted) | Requires config | Add the FilesMatch block below to your .htaccess or VirtualHost config. |
| Express / Node static | Requires config | If using express.static(), only serve a dedicated public/ subdirectory — never the project root. |
| Docker + Nginx | Requires config | Ensure .env is not COPY'd into the image. Use a .dockerignore file. |
Nginx fix:
# /etc/nginx/sites-available/yoursite.conf
server {
# ... your existing config ...
# Block all dotfiles — place this before your main location / block
location ~ /\.env {
deny all;
return 404; # Return 404, not 403 — avoids confirming the file exists
}
# Optional: block ALL dotfiles and dot-directories
# location ~ /\. {
# deny all;
# }
}Reload Nginx after saving:
sudo nginx -s reload
# or
sudo systemctl reload nginxSee the Nginx deny directive documentation for the full access module reference.
Apache fix:
# Add to your .htaccess or VirtualHost config
<FilesMatch "^\.env">
Order allow,deny
Deny from all
</FilesMatch>
# Apache 2.4+ syntax (Require directive)
# <FilesMatch "^\.env">
# Require all denied
# </FilesMatch>The immediate stop-gap for self-hosted servers
If the file is live right now and you need to stop serving it before you can apply a config change:
# Rename the file immediately — stops serving it right now
# This is a 30-second containment step, NOT a complete fix
mv /path/to/your/project/.env /path/to/your/project/.env.disabled
# Then restart your web server
sudo systemctl restart nginx
# or
sudo systemctl restart apache2This buys you time. It doesn't rotate the secrets. Do this, then fix the config properly.
Rotate every secret in the file
This is the section that doesn't exist in most guides. When a single API key is exposed, you revoke one key. When the entire .env is exposed, you rotate everything. Every variable.
A leaked SMTP password enables phishing from your domain. A leaked JWT secret means every active user session is compromised. Don't skip variables because they seem less critical.
| Variable (examples) | Service | Where to rotate | Notes |
|---|---|---|---|
DATABASE_URL, POSTGRES_URL |
Your DB host | Change the DB user password, update connection string in your hosting platform | Restart app after updating |
NEXTAUTH_SECRET, JWT_SECRET |
Generated | openssl rand -base64 32 in terminal, deploy new value |
All existing user sessions will be invalidated on next deploy |
STRIPE_SECRET_KEY |
Stripe Dashboard → API keys | Click "Roll key" next to the exposed secret | Test webhook signing before deploying |
OPENAI_API_KEY |
platform.openai.com/api-keys | Revoke and generate a new key | Update usage limits on the new key |
ANTHROPIC_API_KEY |
console.anthropic.com/settings/keys | Archive the key, create a new one | — |
GITHUB_CLIENT_SECRET |
GitHub → OAuth App settings | Regenerate secret | Verify callback URL is unchanged |
RESEND_API_KEY, POSTMARK_SERVER_TOKEN |
Provider dashboard | Delete and recreate | Verify sending works before declaring done |
SUPABASE_SERVICE_ROLE_KEY |
Cannot self-rotate | Contact Supabase support | This key cannot be rotated from the dashboard — support or project migration may be required |
After rotating, store the new values in your hosting platform's environment variable UI — not in a new .env file on the server. For provider-specific revocation URLs, see our guide on revocation URLs for common services.
Once you've finished rotating, verify that the .env file is no longer accessible. Run our free .env Leak Scanner to check all 13 common paths in one click. Then scan your full repo to catch any hardcoded credentials in your codebase that you might have missed.
Track B: Your .env is Committed to Your Git Repo
Check if the file is tracked
# If this command returns the filename, the file is tracked by git
git ls-files --error-unmatch .env
# See every commit that touched the file — including if it was "removed"
# in a later commit but is still in earlier history
git log --all --full-history -- .envIf git log shows commits, your .env is in git history. Deleting the file from your latest commit doesn't remove those earlier commits. Anyone with repo access can still read the file.
Stop tracking it — the immediate fix
# Remove .env from git tracking without deleting it from your local filesystem
# The --cached flag is critical: without it, git rm deletes the file
git rm --cached .env
# Add to .gitignore so it's never tracked again
echo ".env" >> .gitignore
# Commit and push
git add .gitignore
git commit -m "chore: remove .env from git tracking"
git pushThis stops .env from appearing in future commits. It does not remove it from git history. If your repo is public, anyone who cloned before this commit still has the file. This is containment — not a complete fix.
Remove it from history
The full process involves rewriting your git history with git filter-repo, force-pushing all branches, and having collaborators re-clone. That's covered in detail in our guide to removing secrets from git history — including the exact commands, the GitHub cache note, and the BFG Repo Cleaner alternative. Don't try to reproduce the git filter-branch process here — it's deprecated. Use filter-repo.
Rotate the secrets
Treat git exposure the same as web server exposure. If the repo was public — even briefly — assume the file was seen. Rotate everything using the rotation table in Track A above.
Why .env Files End Up Exposed in Production
If you've contained the incident, here's why this happens — so you can make sure it doesn't happen again.
The .gitignore was added after the first commit
This is the most common cause. Developers understand vibe coding security risks conceptually, but the mechanics of .gitignore trip up even experienced developers.
.gitignore only prevents untracked files from being added to git. It does not retroactively untrack files already committed. The pattern: create a project, create .env, make some commits, then add .env to .gitignore — and the file is already in history.
Veracode's State of Software Security 2025 found that 45% of AI-generated code has at least one vulnerability. When AI tools scaffold projects, they don't always configure .gitignore correctly for environment files — and AI-generated code vulnerabilities in this area are more common than most developers expect. The .gitignore entries are there, but they appear after the .env has already been staged.
The .env file was included in a Docker image or build artifact
COPY . . in a Dockerfile copies everything, including .env. If the image is pushed to a public registry, the .env is in the image layers. Adding .dockerignore after the image is built doesn't remove the file from existing layers — the image must be rebuilt.
.dockerignore is a separate file from .gitignore. You can correctly ignore .env in git and still accidentally COPY it into a Docker image if .dockerignore is missing the entry. See the Docker .dockerignore documentation for the format.
# .dockerignore — separate from .gitignore
.env
.env.local
.env.production
.env.*
*.envThe NEXT_PUBLIC_ prefix is a different kind of leak
Variables prefixed NEXT_PUBLIC_ in a Next.js .env.local are embedded into the client-side JavaScript bundle — readable by anyone who views source or uses DevTools. This is by design (that's how Next.js exposes configuration to the browser), but it's frequently misused for secrets that should stay server-side.
NEXT_PUBLIC_STRIPE_SECRET_KEY is a real pattern we've seen in production repos. The fix: secrets are never NEXT_PUBLIC_. If a value needs to be in the browser, it's not a secret. If it's a secret, it belongs in a server-side API route, not the bundle.
This is a distinct exposure path from .env being served as a file — a 404 on the curl check doesn't protect you from this one. If you're using Supabase, the Supabase security checklist covers the related risks around the anon key vs. service role key distinction in detail.
Prevention: Make This Structurally Impossible
Once the immediate incident is resolved, set up your project so this can't happen again.
Use .gitignore from day zero
Create .gitignore before you create .env. Not after.
# .gitignore
# Environment variables — never commit these
.env
.env.local
.env.development
.env.production
.env.*.local
*.env
# Do commit the example file (no real values)
!.env.exampleIf the file is already tracked, git rm --cached .env removes it from tracking without deleting it locally. Adding it to .gitignore afterward doesn't undo tracking — you need both steps.
Create a .env.example with no real values
A committed .env.example documents which variables your project needs without exposing any actual values. Developers copy it to .env and fill in their own values.
# .env.example — commit this, not .env
DATABASE_URL=postgresql://user:password@host:5432/dbname
NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32
STRIPE_SECRET_KEY=sk_live_...
OPENAI_API_KEY=sk-proj-...Add .env to .dockerignore
# .dockerignore
.env
.env.local
.env.production
.env.*
*.env
.git
node_modules.dockerignore is separate from .gitignore. Both need the entry.
Block dotfiles at the server level, always
For self-hosted deployments: make the Nginx dotfile deny rule part of your standard server config template. The deny rule costs nothing and prevents this class of exposure entirely.
# Standard block for all self-hosted Nginx configs
# Returns 404 instead of 403 — avoids confirming dotfiles exist
location ~ /\. {
deny all;
return 404;
}Use platform environment variable injection in production
In production, .env files shouldn't exist on the filesystem. Vercel, Railway, Render, and Fly.io all provide dashboard or CLI tooling to inject environment variables at runtime without deploying a file. This makes .env web server exposure structurally impossible.
This is the core principle from the Twelve-Factor App methodology: config belongs in the environment, not in the codebase. Platform injection is how you implement it.
For everything related to Next.js security configuration — headers, cookies, CSP — that guide covers the HTTP layer once your secrets are secured. You can also check your security headers for free to verify your HTTP configuration.
Step 6: Verify Your Fix Worked
Re-run the curl check
curl -I https://<YOUR_DOMAIN>/.envExpected result: 404 Not Found. A 403 means the file is still on the server but blocked — better, but remove the file. A 200 means the fix didn't work.
Check all variant paths
# Loop through all common .env variants — any 200 is a problem
for f in .env .env.local .env.production .env.development .env.backup .env.example; do
echo -n "$f: "; curl -s -o /dev/null -w "%{http_code}" "https://<YOUR_DOMAIN>/$f"; echo
doneEvery line should return 404. If .env.example returns 200, that's fine — it contains no real values by design. If any other path returns 200, there's still a problem.
Run a full scan
Data Hogo's URL scanner checks for accessible .env variants and probes all common paths as part of every scan. The secrets engine checks your git history for hardcoded credentials. When we scan deployed URLs, one of the first things the scanner checks is whether common dotfiles are accessible — and we see this finding more often than most developers expect. The common thread: developers believed their hosting platform protected them when it only does for specific platforms.
Check if your .env is exposed right now — free, instant, no signup. For a full codebase audit including git history and hardcoded secrets, scan your repo free — the free plan includes 3 scans per month, no credit card required.
Frequently Asked Questions
How do I check if my .env file is publicly accessible?
Run curl -I https://yourdomain.com/.env from a terminal. A 200 OK response means the file is being served and its contents are publicly accessible. A 403 Forbidden response means the file exists on the server but access is currently denied — it should still be removed or the server config should be hardened. A 404 Not Found response means the server is not serving the file at that path, but doesn't rule out exposure via git history. Check common variant paths as well: .env.local, .env.production, .env.development, and .env.backup. Data Hogo's URL scanner checks all of these paths as part of a URL scan and surfaces any that return 200.
Why is my .env file being served by my web server?
Web servers serve files from a document root directory unless explicitly told not to. If your .env file is in the document root — because it was committed to git and deployed, manually uploaded, or included in a Docker image — most server configurations will serve it as plain text. Nginx and Apache don't block dotfiles by default. Managed platforms like Vercel, Railway, and Render handle this at the platform level, so .env files aren't served there. Self-hosted deployments using Nginx or Apache need an explicit configuration rule to block dotfile access: a location ~ /\.env { deny all; return 404; } block in Nginx, or a FilesMatch directive in Apache.
What happens if my .env file is exposed in production?
Every secret in your .env file is now accessible to anyone who knows to check for it — and automated scanners probe for exposed .env files constantly as part of security reconnaissance. A typical developer's .env contains database connection strings with passwords, payment API keys like Stripe secrets, authentication secrets like JWT signing keys, email service credentials, and third-party API keys. Each enables a different class of attack: a database password gives read and write access to your data, a Stripe secret key allows API calls to your payment account, a JWT secret allows forging authentication tokens for any user in your system. Treat a full .env exposure as a critical incident and rotate every secret — not just the ones you think are most sensitive.
How do I stop Nginx from serving .env files?
Add a location block to your Nginx server configuration that denies access to dotfiles. The most targeted version is location ~ /\.env { deny all; return 404; }. For broader protection that blocks all dotfiles, use location ~ /\. { deny all; }. Place this block inside your server {} block, before your main location / block, and reload Nginx with sudo nginx -s reload or sudo systemctl reload nginx. Re-run curl -I https://yourdomain.com/.env to confirm the response is now 404. Using return 404 rather than just deny all is preferable — it avoids revealing that a file exists at that path.
How do I rotate all my secrets after a .env file was exposed?
Treat full .env exposure as a multi-secret rotation event. Open the exposed file and list every variable. For each secret: change database passwords at the database user level and update the connection string in your hosting platform's environment variables, regenerate JWT and session secrets with openssl rand -base64 32 and redeploy (which invalidates all active user sessions), roll payment API keys like Stripe from the API keys dashboard and test webhook signing before deploying, revoke and replace AI API keys from the provider's console, and rotate email service credentials from the provider's dashboard. After rotating, store new secrets in your deployment platform's environment variable store — not in a new .env file. Verify your application starts and functions correctly before declaring the incident resolved.
You're going to fix this. The steps above cover the full scope — stop the active serving, clean the history, rotate the secrets, verify it worked. Most developers who go through this process come out the other side with a more secure setup than they started with: platform-injected variables, proper .gitignore, dotfile blocking at the server level.
Check if your .env is exposed → | See what Data Hogo finds in your full repo →
Related Posts
OWASP A07 Authentication Failures Guide
OWASP A07 authentication failures with real code examples: weak passwords, JWT without expiry, localStorage tokens, no rate limiting, and how to fix each.
OWASP A01 Broken Access Control Guide
Broken access control is the #1 OWASP risk. This guide explains IDOR, missing auth checks, JWT tampering, and how to fix them with real Next.js code examples.
OWASP A02 Cryptographic Failures Guide
OWASP A02:2021 Cryptographic Failures is the #2 web vulnerability. Learn how plaintext passwords, weak hashing, and hardcoded keys expose your users — with real code examples.