Security Guides

A case where Laravel's .env was publicly readable: a shared-hosting placement mistake and how to fix it

On shared hosting, Laravel's .env, .git and DB dumps become readable over HTTP when the whole project sits under the web root. The cause and the three-step fix.

Published 2026-06-07 Updated 2026-10-04 6 min read
On this page

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.

Case file — INCIDENT FILE
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 misplacement → dozens of them
Permanent fix
Body outside the docroot + only public/ symlinked

The worst files are .env and .git

.env holds every secret in one file: 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 here

Correct layout

~/laravel/app/    ← body outside the web root
├── .env  .git/   ← unreachable over HTTP = safe
└── public/       ← only this, exposed via symlink
        └── index.php

Typical files that returned 200 OK to the outside world:

FileWhy it's dangerous
.env / .env.bk_*DB auth, mail, external API keys, APP_KEY — all in plaintext
the .git/ directorygit clone restores source and full history
composer.lock / package-lock.jsonexact dependency versions = tells attackers which known CVEs apply
credentials.jsonOAuth client secrets, bundled
*.sql / db-*.sql.gza full database dump, tens of MB compressed

The most dangerous part: the same mistake spreads

Once you lay it out this way, each new app of the same kind copies the same misplacement. 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 needs a new rule for every new file, so something always gets missed. You'll forget composer.lock; new filenames won't be covered. Treat it as a temporary measure, and make the lasting 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:

  1. 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.
  2. OAuth client secrets.
  3. APP_KEY: regenerating can break sessions and make encrypted DB columns undecryptable — check what it affects first.
  4. Mail/SMTP, DB credentials.

A rotation mistake that actually happened

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 root 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 cache

In 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.

✗ Dangerous: body inside the web root

— web boundary (readable over HTTP) —

.env · .git · vendor · public

↑ all inside the line = anyone can read them

✓ Safe: body outside the web root

— web boundary (readable over HTTP) —

public/ only (symlink)

.env · .git are outside = unreachable

One thing decides it: inside or outside the web root. If .env and .git sit inside it, anyone can read them over HTTP.

Problems we hit while fixing it

  • 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 require by absolute path from a small index.php inside it (the "bootstrap-redirect" shape) for stability.
  • opcache remembers a broken state: opcache can memorize a failed index.php load 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 while https://example/sub/ still shows the contents — an easily missed path. When inspecting, block the parent-domain path too.
  • Hardcoded secrets in config/*.php: combined with a public .git, history reveals every rotation. Always go through env() and never put real values in fallback defaults.

Lesson: the default server layout makes this mistake easy

Early shared-hosting practice put everything directly under public_html. When Laravel (which expects only public/ exposed) came into wide use there, 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 helped spread it. 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 under public_html/<app>.
  • Self-check after deploy: confirm /.env and /.git/config aren't fetchable from the outside.

This site builds this "inspect your own site yourself" habit into its learning track. → What's dangerous about .env and API keys

FAQ

QMy .env is exposed — what do I do first?
A

First 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?
A

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?
A

It works as a temporary measure, but every new filename needs a new rule, so something always gets missed. The lasting fix is the structural change: put the app body outside the docroot and expose only public/ via a symlink.