For: anyone running several sites on shared/rental hosting or a VPS, deploying into public directories (public/, public_html/, htdocs/, etc.). No attack steps — only auditing your own server and closing the holes. For protecting the .env itself, see protecting .env on shared hosting.
This site's view: where you put it IS the access control
Secrets leak more from placement accidents than from clever attacks. The webroot is "a place anyone can come fetch from by URL." Put a secret there and even the strongest server password is meaningless. This site's baseline is to keep secrets out of both the webroot and git (→ storing secrets safely). The idea is simple: put only things you don't mind anyone seeing in the public directory.
public/ = fetchable by anyone via URL
OK to place
images, CSS, JS — public assets
Never place
.env, token.json, .sql, .git, .bak
Above the app root = not served
Put here
secrets, tokens, credentials, backups. Perms 600 (owner-read only). Unreachable by URL.
Why "leftovers in the public directory" happen
The common pattern is a file placed for convenience, or one that shipped inside a boilerplate, quietly staying public.
For example, a JSON holding an access token for a third-party integration sits under public/ for a long time — and if it's from a shared template, the same file is copied to the same spot on many sites, so they all share the same problem. Fixing one site is meaningless if the others still have the file. Equally dangerous: leftover .env, database backups (.sql), a .git directory, editor backups (*.bak), and shortcuts (related: path traversal · an entire .env exposed).
Files that get left behind, and why each is dangerous
"Secret file" usually brings .env to mind, but what actually gets left behind is much broader. The testing guide from OWASP (a non-profit focused on web security) also lists old file versions, backups, and files nothing links to as things to check.
| Type | Typical names | What is at risk | Where it belongs |
|---|---|---|---|
| Config files | .env, config.php.bak, settings.json | Database passwords and API keys written in plain text | Above the app root (not served) |
| Tokens and credentials | token.json, credentials.json, *.pem, *.key | Direct login to an external service or another server | Outside the webroot, perms 600 |
| Manual or editor backups | login.php.old, index.php~, .index.php.swp, *.orig | With a changed extension the file may not run as code, so its source is shown as plain text | Don't create them in production; edit locally |
| Database dumps | backup.sql, dump.sql.gz | Users' personal data and password hashes, all in one file | A dedicated backup location, ideally off the server |
| Site archives | site.zip, www.tar.gz | Contain .env and the full source code | Delete right after extracting |
| Version control data | .git/ | Secrets from past commits may be recoverable | Outside the public directory |
| Values embedded at build time | inside assets/index-xxxx.js | Variables starting with VITE_ or NEXT_PUBLIC_ end up in browser JavaScript | Keep secrets in server-side variables only |
The last row is easy to miss. Even if you never placed a file, a build can push a secret into the public directory. Vite's official documentation states plainly that VITE_ variables must not contain sensitive information such as API keys.
Audit your own server
First, mechanically surface "any secret-looking files in my own public directory." This is inspecting your own assets, not peeking at anyone else's site.
Search under public directories for secret-looking files
public/ (token/credential JSON, .env, keys, shortcuts).Assume the same file exists elsewhere — check every host and site
Decide per file whether it truly needs to be public
# audit under your own public directories (inspecting your own assets)
find ~ -path '*/public/*' \( -iname '*token*.json' -o -iname '*credential*.json' \
-o -iname '*.bak' -o -iname '*.sql' -o -iname '.env*' \) 2>/dev/null
# an exposed .git directory is dangerous too
find ~ -path '*/public/*/.git' -maxdepth 8 2>/dev/nullHow to check your own environment
The find above is a starting point. Working through the five checks below catches leftovers that a filename search alone misses. Run them only against your own server and your own domain. Checks specific to .env (looking at the first bytes of the response, confirming .htaccess actually works) are covered in protecting .env on shared hosting.
Search for backups and archives too
Public directory names vary by host, so include public_html, htdocs and www. For example, run find ~/public_html \( -name '*~' -o -name '*.swp' -o -name '*.old' -o -name '*.orig' -o -name '*.zip' -o -name '*.tar.gz' -o -name '*.pem' -o -name '*.key' \) 2>/dev/null and confirm, file by file, that each result is really meant to be public.
Make sure directory listings are off
Open a directory that has no index.html (for example /uploads/) in a browser. If you see a list of file names, the web server's directory listing feature is on. A listing lets people spot leftovers without knowing their names. A 403 or 404 is what you want.
Search the built JavaScript for secrets
If you build a frontend, search the output folder you deploy (dist/, .next/static/ and so on). Use something like grep -rlE 'BEGIN [A-Z ]*PRIVATE KEY|secret|password' dist/, plus the prefix your service uses for its keys. If you find one, check whether the variable has a browser-facing name (VITE_, NEXT_PUBLIC_).
Check that secrets outside the webroot aren't readable by other users
Moving a secret out of the webroot doesn't help if loose permissions let another account on the same server read it. find ~ -maxdepth 3 \( -name '.env*' -o -name '*.pem' -o -name '*token*.json' \) -perm /o+r 2>/dev/null lists secrets readable by anyone other than the owner; fix them with chmod 600. No output means you're fine.
Check whether search engines have indexed anything
Search Google for site:yourdomain and look for files you don't recognise (.sql, .zip, .txt and so on). If you use Search Console, the Pages report lists the URLs it has indexed. What to do if something is still in search results is covered under common mistakes below.
What to do when you find one
Do three things as a set — remove, revoke, move. Skip any one and the fix is incomplete.
Remove from the webroot so the URL can't fetch it
Revoke/reissue any key or token that may have leaked
Keep secrets outside the webroot at perms 600
By server: settings that refuse to serve these files (as a backstop)
The real fix is not placing the files there. On top of that, add a deny rule on the web server so that anything placed by mistake still isn't served. What you can use depends on your setup.
| Setup | How to deny | Directory listing | Watch out for |
|---|---|---|---|
| Apache (VPS) | <FilesMatch> + Require all denied in a <Directory> block or .htaccess | Turn off with Options -Indexes | In Apache 2.4 AllowOverride defaults to None, so .htaccess is ignored unless you allow it |
| Nginx (VPS) | deny all; inside a location | autoindex is off by default | Doesn't read .htaccess. Regex location blocks are checked in the order written and the first match wins |
| Shared hosting | Usually .htaccess (Apache-compatible) | Via the control panel or .htaccess | Confirm it works with a dummy file that holds no secrets |
| Static hosting / CDN | Whatever is in the output folder is published as-is | Depends on the service | Rely on keeping secrets out of the output folder, not on deny rules |
Apache example (where .htaccess is enabled):
Options -Indexes
<FilesMatch "(^\.|~$|\.(bak|old|orig|swp|sql|zip|tar|gz|pem|key)$)">
Require all denied
</FilesMatch>Nginx example (inside a server block):
# Allow the path used for automatic certificate renewal (Let's Encrypt) first
location ^~ /.well-known/acme-challenge/ { }
# Deny files and directories starting with a dot (.env, .git, ...)
location ~ /\. { deny all; }
# Deny backups and archives
location ~* (~|\.(bak|old|orig|swp|sql|zip|tar|gz|pem|key))$ { deny all; }In Nginx, when a request matches a location marked ^~, regex locations are not checked. That way /.well-known/acme-challenge/, used for certificate renewal, is still served despite the rule that denies paths starting with a dot. After changing the config, check the syntax with nginx -t, reload, and confirm a dummy file returns 403. If your site offers ZIP files or similar for download, remove that extension from the deny list.
The placement people fall into (dangerous)
- a JSON token for an integration sits in
public/ - a secret file that shipped in the boilerplate stays public
- backups (
.sql/.bak) or.gitleft under the public dir - fix one site and call it "handled"
The correct placement
- the public directory holds only publicly-shareable things
- secrets live outside the webroot at perms 600
- sensitive extensions are denied from being served
- find one and audit every host and site
Make 'only public-safe things' a standing rule
Rules beat one-off fixes. Decide that the public directory holds only public-safe files, and as a rule never put secrets, backups, version-control data (.git), or config files there. When you update a boilerplate, strip the secret from the template itself — otherwise every site you mass-produce next keeps copying the same secret file.
Common mistakes and how to fix them
- Relying on a deny rule and leaving the file in place: a server move or config rewrite can drop the rule, and the file is public from that day. Nginx doesn't read
.htaccessat all. Treat the rule as a backstop and move the file out of the public directory. - Editing files directly on the production server: editors leave backups ending in
~or.swpbehind. The OWASP testing guide also advises against editing files in place on the web server. Edit locally and ship changes through your deploy. - Copying the whole project folder into the public directory:
.env,.gitand test data go out with it. Serve only the build output folder, and name exactly what gets copied. - Denying every dot path and breaking certificate renewal: Let's Encrypt places its check file under
/.well-known/acme-challenge/. As in the Nginx example above, allow that path first. - Putting secrets in
VITE_orNEXT_PUBLIC_variables: they are embedded in the built JavaScript for anyone to read. Use them only in server-side code, and revoke any key you have already shipped this way. - Deleting the file and stopping there: if a search engine indexed it, it can linger in results. Confirm the URL returns 404 or 410, then hide it temporarily (about six months) with the Removals tool in Google Search Console while recrawling clears it.
A real case: hidden personal data in a published report
The Department of Health Care Finance (DHCF), which runs Medicaid (public health coverage for people on low incomes) in Washington, D.C., said two statistical reports posted on its website from 2023 until July 2026 contained the underlying personal data as "hidden fields". The reports were built to display only aggregate figures, but the files still held recipients' Medicaid numbers, dates of birth and more. Its filing with the U.S. Department of Health and Human Services (HHS) put the number affected at 399,086. DHCF learned of the issue on July 21, 2026 and removed the reports.
This is not a case of a forgotten secret file, but the root is the same:
- A file in a public directory can be fetched in full, not just the part shown on screen
- Information the publisher thinks is "not visible" is the easiest to miss in a review
- It can stay posted for years before anyone notices
A step that checks "what is actually inside this file" before publishing prevents both leftover files and hidden data. The full account is in the Washington, D.C. Medicaid data exposure.
What this site does itself
This site's baseline is to keep secrets — keys, tokens, connection strings — out of both the public directory and the code repository. Deploy artifacts contain only built public assets; secrets are held as environment variables of the runtime, in a place that isn't served. The reason: this article's incident class is the typical case where one leftover file leaks everything to anyone who requests its URL. We design placement itself as access control, and run this audit on the same cadence as the vulnerability-response inventory.
Read next
- Stacks: protecting .env on shared hosting · the vulnerability-response playbook (inventory)
- Glossary: .env files and secrets · path traversal
- Incident: an entire .env exposed
- Storage: storing secrets safely
- In the cloud: public buckets (the same problem, off the server)
- When the provider is breached: what to do when your web host is compromised (assume whatever sits there has been read)
- Real case: Medicaid numbers left in published reports (Washington, D.C.) (a published file exposed in full)
Sources
- IPA, "20 Checkpoints for Secure Website Operation and Management" (Japanese; files that should not be public, search engine caches): ipa.go.jp
- OWASP, "Web Security Testing Guide: Review Old Backup and Unreferenced Files for Sensitive Information" (.old, ~, .swp backups; don't edit in place on the server): wstg.owasp.org
- Apache HTTP Server 2.4, "Upgrading to 2.4 from 2.2" (
AllowOverridedefaults toNone) and "mod_autoindex" (Options +Indexesgenerates listings): httpd.apache.org, httpd.apache.org - nginx, "ngx_http_core_module: location" (matching order and
^~) and "ngx_http_autoindex_module" (autoindexdefaults tooff): nginx.org, nginx.org - Let's Encrypt, "Challenge Types" (HTTP-01 uses
/.well-known/acme-challenge/): letsencrypt.org - Vite, "Env Variables and Modes" (
VITE_variables are exposed to client code): vite.dev - Next.js, "Environment Variables" (
NEXT_PUBLIC_variables are inlined into browser JavaScript): nextjs.org - Google Search Console Help, "Removals tool" (temporary removal lasts about six months): support.google.com
- District of Columbia DHCF, "Notice of Data Incident": dhcf.dc.gov; HIPAA Journal (number reported to HHS): hipaajournal.com