Incidents
Laravel apps' .env was readable by the whole world — the most common shared-hosting mistake
On shared hosting, Laravel apps end up with .env, .git, DB dumps, and OAuth secrets readable over HTTP. The cause: the whole project under the web root instead of only public/. The three-step fix, and why it's an industry-wide bad pattern.
A misconfiguration that really happens on shared hosting, with domains and values removed and turned into a lesson. This is not about finding other people's sites — it's about securing your own (or an inherited) deployment.
- Class
- Exposure of .env / .git / DB dumps (misconfiguration)
- Severity
- Critical (every secret readable over HTTP)
- Cause
- Laravel body placed directly under public_html (only public/ should be exposed)
- Propagation
- Each new app copied the same hole → dozens of them
- Permanent fix
- Body outside the docroot + only public/ symlinked
The worst files are .env and .git
.env is a keyring: DB auth, mail, external APIs, the app encryption key (APP_KEY). With .git public, attackers recover not just current values but every past rotation by walking the git history. See What is .env.
What was happening
On the shared host, many Laravel apps were laid out this way. Here are the dangerous and correct layouts side by side:
Dangerous layout (common)
~/public_html/
└── app/ ← whole Laravel (wrong)
├── .env ← readable at /app/.env
├── .git/ ← clone restores full history
├── vendor/ config/ storage/
└── public/ ← the ONLY thing meant to be hereCorrect layout
~/laravel/app/ ← body outside the web root
├── .env .git/ ← unreachable over HTTP = safe
└── public/ ← only this, exposed via symlink
└── index.phpTypical files that returned 200 OK to the outside world:
| File | Why it's dangerous |
|---|---|
.env / .env.bk_* | DB auth, mail, external API keys, APP_KEY — all in plaintext |
the .git/ directory | git clone restores source and full history |
composer.lock / package-lock.json | exact dependency versions = a known-CVE targeting list |
credentials.json | OAuth client secrets, bundled |
*.sql / db-*.sql.gz | a full database dump, tens of MB compressed |
The scary part: propagation
Once you lay it out this way, each new app of the same kind copies the same hole. One misconfiguration becomes dozens. The rule: the moment you notice, inspect them all.
The three-step fix
Step 1 — First aid: block sensitive files at the HTTP level
First, stop "more leaking." It's reversible and doesn't affect normal pages. Add a deny block at the top of public_html/.htaccess (a defensive config example):
# === SECURITY BLOCK ===
# 404 anything under .env / .git
RedirectMatch 404 (?i)/\.(env|git)(\..*)?(/|$)
# deny backups, dumps, credential files
<FilesMatch "(?i)\.(sql|sql\.gz|bak|old|swp|save|orig|tgz)$|^credentials\.json$|\.bk[._]">
Require all denied
</FilesMatch>
# if Laravel internals are visible at /<app>/storage/... etc.
RedirectMatch 403 (?i)^/[^/]+/(storage|bootstrap/cache|config|database|resources|routes|tests|vendor)(/|$)
# === END ===But a blacklist is fundamentally whack-a-mole. You'll forget composer.lock; new filenames add new holes. Treat it as best-effort first aid, and put the real fix in Step 3.
Step 2 — Rotate keys (assume they were seen)
Even after blocking with .htaccess, treat the secrets as already seen. In priority order:
- External API keys (top priority, immediate): they map to billing and account takeover. Revoke + re-issue OAuth
CLIENT_SECRET, API keys, and cloud credentials, scoped to least privilege. - OAuth client secrets.
APP_KEY: regenerating can break sessions and make encrypted DB columns undecryptable — check the blast radius first.- Mail/SMTP, DB credentials.
A rotation trap (actually hit in the field)
With some OAuth providers, re-issuing the client secret also invalidates existing refresh_tokens — refresh with stored tokens fails and causes a production outage. Always have a re-authorization flow ready before re-issuing. And update your local .env / config too, or the next deploy reintroduces the old value.
Step 3 — Restructure: move the body outside the docroot (the real fix)
Re-lay each app like this:
# 1. Move the body outside the web root (same filesystem → mv is instant, atomic)
mv ~/example/public_html/app ~/example/app-laravel
# 2. Put only a minimal bootstrap in the web root
mkdir -p ~/example/public_html/sub
cat > ~/example/public_html/sub/index.php <<'PHP'
<?php require __DIR__ . '/../../app-laravel/public/index.php';
PHP
# 3. Copy Laravel public/.htaccess, symlink static assets, clear config cacheIn this shape, .env, .git, and vendor/ simply don't exist in the web root, so you're safe without relying on .htaccess rules. Full steps are in Keeping .env off the public web on shared hosting.
Traps "not in the textbook"
- Subdomain scope problem: re-pointing a registered subdomain's docroot to another tree via symlink may not follow and returns 500. → Keep the docroot a real directory and
requireby absolute path from a smallindex.phpinside it (the "bootstrap-redirect" shape) for stability. - opcache remembers a broken state: opcache can memorize a failed
index.phpload and keep returning 500 even after you rebuild. Last resort: change the filename (entry.php, etc.). - An "unregistered" subdomain docroot readable via the parent:
https://sub.example/may be invalid whilehttps://example/sub/still shows the contents — an easily missed entrance. When inspecting, block the parent-domain path too. - Hardcoded secrets in
config/*.php: combined with a public.git, history reveals every rotation. Always go throughenv()and never put real values in fallback defaults.
The biggest lesson: this isn't "one person's mistake"
Early shared-hosting culture put everything directly under public_html. Laravel (which expects only public/ exposed) flowed into that, and a dangerous "project under public_html/<app>/, expose its public/" layout became the de-facto standard — vendors' own docs use public_html-based examples, which makes it worse. So fix it with process, not vigilance:
Prevent recurrence with process
- Make the deployment default "body outside the docroot, only
public/symlinked". - CI/lint checks: never commit
.env; never place a project directly underpublic_html/<app>. - Self-check after deploy: confirm
/.envand/.git/configaren't fetchable from the outside.
ITD builds this "inspect your own site yourself" habit into its learning track. → What's dangerous about .env and API keys
Read next
- Glossary: What is .env · What is a CVE
- Defense: Keeping .env off the public web on shared hosting
- Incident: When a stolen API key got billed for fraud (the other leak path — runtime)
FAQ
QMy .env is exposed — what do I do first?
Stop the bleeding (block the path via .htaccess), then rotate the external API keys and OAuth secrets in .env in priority order (assume they were seen), then move the app body outside the web root so you're safe without relying on rules.
QWhy is putting Laravel under public_html dangerous?
Laravel is built to expose only public/. Put the whole project in the docroot and everything above it — .env (all your secrets), .git (recoverable history), vendor/ — becomes readable over HTTP.
QIs blocking with an .htaccess blacklist enough?
It works as first aid but is fundamentally whack-a-mole — every new filename adds a hole. The real fix is the structural change: put the app body outside the docroot and expose only public/ via a symlink.