An exposed .env is a database password in a URL
Published · updated
Framework projects keep their secrets in a dot-file next to the code. When that file lands in the web root, one plain GET returns the database password, the mail credentials and every API key — and bots request it on every site they find.
What is inside, and why it matters
A typical Laravel, Symfony or Node project keeps APP_KEY, DB_PASSWORD, mail credentials, payment keys and third-party tokens in .env. It is not a configuration detail — it is the complete set of credentials the application uses, in plain text, with no authentication in front of it.
With DB_PASSWORD and a reachable database port an attacker copies the customer table. With APP_KEY they can forge session cookies and log in as an administrator without a password. With mail credentials they send invoices from your domain.
How it becomes public
- Deployed to the web root. The project is copied whole (git clone, FTP, rsync) into the directory the web server serves, instead of into a parent with only
public/exposed. - Copies with guessable names.
.env.local,.env.production,.env.backup,.env.bak— created during a migration and never removed. - Editor and archive leftovers.
.env.swp,site.zip,backup.tar.gzthat contains the project folder. - Wrong server root. An nginx
rootpointing one level too high exposes the whole project, including.gitand.env.
Test for it without copying secrets into a chat
# только код ответа: 200 — файл отдаётся, 403/404 — закрыт
for p in .env .env.local .env.production .env.bak; do
printf '%-18s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' "https://example.com/$p")"
done
Do not print the body to a terminal you share, do not paste it into a ticket, and do not attach it to a chat with a contractor: the first line is usually a working password. If you need to confirm the content is real, pipe it into head -c 0 and look only at the status code and content-length.
Our checker does the same four requests among its exposed-file checks and reports whether the file is downloadable — without storing or displaying the contents.
If it is exposed: remove, rotate, then watch
- Delete the file and every copy — including archives that contain it.
- Rotate every secret it held. Database password, mail credentials, API keys, payment keys. Changing the password matters only after the file is gone; doing it in the other order leaves the window open.
- Rotate
APP_KEYlast, and expect consequences. All existing sessions and encrypted values become invalid — users are logged out, and encrypted columns may need re-encryption. - Search the access log for
GET /.env. If it was requested from an address that is not yours, treat the credentials as used, not merely exposed. - Look for follow-up activity: new admin users, changed mail rules, exports of customer data, new cron entries.
Bots request /.env on new domains within hours of the first certificate being issued, so “nobody knows about it” is not a working assumption.
Close the class of problem, not one file
# nginx: все dotfiles недоступны, до location-ов приложения location ~ /\.(?!well-known/) { deny all; access_log off; } # Apache 2.4 <FilesMatch "^\."> Require all denied </FilesMatch>
Keep .env one level above the served directory, deploy through a repository or CI with an exclude list, and add a check to the release pipeline that requests /.env and fails the deploy on 200. The patch checklist covers the same principle for PHP itself: the fix belongs where the whole class is closed.
Questions
The file returned 403 — is that enough?
It means the server refused the request. Check the same rule covers .env.local, .env.bak and archives; a rule written for one filename is not a rule for dotfiles.
How long was it exposed?
Access logs answer that: search for requests to /.env and look at the first and last date. If the log rotates weekly and the file was deployed months ago, assume the earlier period is unknown.
Do I need to rotate the database password if nobody downloaded it?
Treat any 200 response in the log as a download. If there is no such entry and the file was only briefly live, rotate anyway — the cost is a five-minute restart, the alternative is an incident review.
Is hiding dotfiles in nginx enough for a Node or Go app?
It closes the server-level path. Also confirm the application does not serve the file itself (a static handler or a misconfigured public directory), because that path bypasses the server rule.