Some of the most damaging website leaks don't involve any hacking at all. A .env file with database passwords and API keys, or a .git folder holding your full source history, gets deployed into the public web root, and anyone who requests the right URL can download it. Automated scanners request those URLs constantly. This guide explains how these files end up exposed, how to check your own site in about a minute, and the server rules that block them for good.
Key takeaways
- Files such as .env, .git folders and backups left in the public web root can be downloaded by anyone who requests the URL.
- The real fix is keeping secrets outside the web root by pointing the document root at the public folder and deploying build artifacts.
- Add Nginx, Apache or Cloudflare rules that block dotfiles, except /.well-known/, and common backup file extensions.
- If you find an exposed file, assume it has been copied and rotate every secret in it rather than only blocking the URL.
Why this matters
In 2024, Palo Alto Networks' Unit 42 documented an extortion campaign built entirely on exposed environment files. The attackers scanned more than 230 million targets, copied the .env files of at least 110,000 domains, and collected over 90,000 unique environment variables, about 7,000 of them belonging to cloud services. They used those credentials to get into victims' cloud accounts, copy data, and demand ransom. Unit 42 was explicit that this didn't come from any flaw in the cloud providers. It came from files that should never have been public.
OWASP ranks Security Misconfiguration second in the OWASP Top 10:2025, and leftover files on production servers are one of its classic examples.
What gets exposed, and what it gives away
| File or path | How it gets there | What an attacker gets |
|---|---|---|
/.env |
Frameworks (Laravel, Symfony, Node apps) keep config in .env; the web root is the project root instead of public/ |
Database credentials, mail/SMTP passwords, cloud keys, payment API secrets, app encryption key |
/.git/ |
Site deployed with git clone or git pull directly into the web root |
Full source code and history, including secrets you "deleted" in a later commit |
/backup.zip, /site.tar.gz, /db.sql |
Someone made a quick backup in the web root before an update | Everything: code, config, customer data |
wp-config.php.bak, config.php.old, index.php~ |
Manual edits, editor backups | Source code served as plain text, because .bak isn't executed as PHP |
.config.php.swp |
Vim swap file left by an open or crashed editor session | Partial file contents, often credentials |
/.DS_Store |
Files uploaded from a Mac | A listing of filenames in that directory |
/phpinfo.php, /info.php |
Debug file left after setup | Server paths, module versions, environment variables |
/.svn/, /.hg/ |
Older version control systems | Same as .git |
The .bak and ~ cases surprise people. Your server runs config.php through PHP, so visitors only see output. A copy named config.php.bak doesn't end in .php, so the server sends it as a plain file, source and passwords included.
A .git folder is worse than it looks. Even with directory listing disabled, the internal file layout of a Git repository is predictable. Tools can request /.git/HEAD, /.git/config and the object files one by one and rebuild the entire repository, history included.
Check your own site
Run these against your own domain. A 200 status with real content is a problem. 403 or 404 is what you want, and 404 is better, because 403 confirms the file exists.
for p in .env .git/HEAD .git/config .env.backup .env.production backup.zip db.sql wp-config.php.bak .DS_Store phpinfo.php; do
printf "%-22s %s\n" "$p" "$(curl -s -o /dev/null -w '%{http_code}' https://example.com/$p)"
done
Watch out for false comfort. Some sites return 200 with their normal homepage for every unknown URL (a "soft 404"). If you get 200, look at the actual body: curl -s https://example.com/.git/HEAD should not print something like ref: refs/heads/main.
Also check subdomains and staging copies (staging., dev., old.). They're often deployed less carefully than production.
The real fix: keep secrets outside the web root
Blocking rules are a safety net. The real fix is making sure these files are never inside the directory your web server serves.
- Point the document root at the public folder. Laravel's web root should be
/var/www/app/public, not/var/www/app. The same goes for Symfony (public/) and most Node frameworks (dist/orbuild/for static output). - Deploy build artifacts, not repositories. Use a CI pipeline,
rsync --exclude, orgit archiveto ship only the files the site needs. If you must deploy with Git, keep the repository outside the web root and use a--work-treedeploy, or put the web root in a subfolder. - Never back up into the web root. Write backups to a directory the web server can't reach, or straight to off-site storage.
- Use real secret storage where you can. Environment variables set by your host or orchestrator, or a secrets manager, rather than a file on disk.
Block access at the server
Even with a clean deployment, add rules so a future mistake doesn't turn into a leak. The patterns below block dotfiles (except /.well-known/, which Let's Encrypt and other services need) and common backup extensions.
Nginx
Add inside your server block, above other location blocks that might match first:
# Block all dotfiles and dot-directories except /.well-known/
location ~ /\.(?!well-known/) {
return 404;
}
# Block backup, dump and editor files
location ~* \.(bak|old|orig|save|swp|swo|sql|sqlite|log|ini|dist)$ {
return 404;
}
location ~ ~$ {
return 404;
}
Nginx checks regex locations in the order they appear in the config, and uses the first one that matches. Put these before your location ~ \.php$ block, or a request such as /.hidden/shell.php would go to PHP instead of being blocked. Also look for prefix locations using the ^~ modifier (for example location ^~ /uploads/). When one of those matches, Nginx skips regex locations entirely, so add the same rules inside that block.
Archives (.zip, .tar.gz) are left out on purpose, because many sites offer legitimate downloads. If yours doesn't, add zip|tar|gz|tgz|7z|rar to the list.
Apache
In the virtual host or a root .htaccess (requires mod_alias):
# Dotfiles and dot-directories, except /.well-known/
RedirectMatch 404 "/\.(?!well-known/)"
# Backup, dump and editor files
<FilesMatch "(\.(bak|old|orig|save|swp|swo|sql|sqlite|log|ini|dist)|~)$">
Require all denied
</FilesMatch>
Apache's default configuration already denies .htaccess and .htpasswd, but not .env or .git. Don't assume you're covered.
Cloudflare
If your site is behind Cloudflare, you can block these paths at the edge with a WAF custom rule (Security > WAF > Custom rules). Use the expression editor:
(http.request.uri.path contains "/.git/") or
(http.request.uri.path contains "/.env") or
(http.request.uri.path contains "/.svn/") or
(ends_with(http.request.uri.path, ".sql")) or
(ends_with(http.request.uri.path, ".bak"))
Set the action to Block. This works across all origins behind the zone, which is handy if you run several apps. Keep the server rules as well, because origin IPs are sometimes reachable directly.
If you find an exposed file
Blocking the URL stops future downloads, but it doesn't undo past ones. Assume anything in the file has been copied.
- Block access immediately, using the rules above or by deleting the file.
- Rotate every secret in it. Change database passwords, regenerate API keys, rotate cloud access keys, reset SMTP credentials and the app key. For
.gitexposure, include every secret that has ever been committed, not just current ones. - Check access logs for requests to the file. A
200response to an unfamiliar IP or scanner user agent means it was downloaded:
grep -E '"GET /\.(env|git)' /var/log/nginx/access.log* | awk '$9 == 200'
- Review the services those credentials unlock. Look for new cloud users, unfamiliar resources, changed DNS, or outgoing mail you didn't send. Unit 42's victims were hit through cloud keys taken from
.envfiles. - Fix the deployment process so the file can't reappear on the next release.
If you see signs that the credentials were used, treat it as an incident. Our hacked site response plan covers the next steps.
Frequently asked questions
Is returning 403 good enough?
It blocks the download, which is what matters most. A 404 is slightly better, because it doesn't confirm the file exists, so scanners have less reason to come back and try variations.
My host uses a control panel. Can I still do this?
Usually yes. Most Apache-based shared hosts honor .htaccess, so the Apache rules work. More importantly, check that your project files (especially for Laravel) aren't uploaded directly into public_html. Many hosts let you change the document root to a subfolder.
Will blocking dotfiles break Let's Encrypt?
Not with the rules above. They exclude /.well-known/, which is where ACME HTTP-01 challenges live. If you write your own pattern, test renewal afterwards with certbot renew --dry-run.
Does removing a secret from Git history fix a .git exposure?
No. If the repository was downloadable, attackers may already have the old history. Rewriting history is good hygiene for the future, but every exposed secret still has to be rotated.
What to do next
Run the curl loop above against every hostname you own, fix your document root if .env or .git sits inside it, and add the blocking rules as a backstop. To probe for exposed files alongside your security headers and certificate, run a free audit. Then check the rest of your configuration with our HTTP security headers guide.
Sources
Written by the MakeItFlow team
We build the website audit these guides are based on. Every fact is checked against the primary sources listed above . How we research and check
Check your own site in 30 seconds
Run a free audit covering speed, SEO, DNS, email authentication and security headers — and get a prioritised fix list.