Skip to content
Back to Articles

The Folder Nobody Used: How One .htaccess Rule Leaked a Laravel .env to Scanners

Share:

The first sign was a bill. A small business runs a Laravel platform that calls an AI text API a few dozen times a day. One month the API usage jumped to more than ten thousand requests a day. The owner rotated the key, and the requests stopped. The obvious conclusion was "someone stole our AI key".

That was true, but it was the smallest part of the story. The AI key was only the one secret that came with a usage meter. What had actually leaked was the application's entire production .env file: the application encryption key, the database password, the mail password, a live payment provider key, the CAPTCHA secret and the AI key. Automated scanners read it more than 40 times over at least ten weeks. Nobody had to hack anything. The web server handed the file to anyone who asked for it.

This article explains how a forgotten development copy and one well-meant .htaccess rule caused it. It also covers how the exposure was measured without ever printing a secret, the order of the fixes, and read-only commands to check your own servers. All details are sanitized. Domains, names and exact figures from the case have been removed or rounded.

🧩 The setup: a copy nobody used

More than a year earlier, someone made a full copy of the application in a subfolder of the live site, called /dev, to test changes. The copy could be reached in two ways: as example.com/dev/ and as its own subdomain, dev.example.com. To make it work quickly, the production .env was copied in with it, so the copy used the same secrets and the same production database.

The test work ended and the folder stayed. It used no resources and nobody visited it, so nobody remembered it was there.

🚪 The rule that opened the door

The live site was set up the classic shared-hosting way. The document root pointed at the Laravel project folder, not at public/, and a root .htaccess rewrote every request into public/. That setup is not ideal, but it held: a request for /.env on the live site never reached the file.

The /dev copy had its own .htaccess. Someone had added a rule at the top to fix a 403 error on the dev homepage:

# Serve existing files or dirs
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]

In plain words: if the requested file exists on disk, serve it as it is and stop. This rule is common in front-controller setups, and inside a proper public/ folder it is harmless, because nothing secret lives there. In a folder that contains the whole project, it makes every file public:

  • /dev/.env came back as plain text with HTTP 200.
  • /dev/.bash_history was served too. It contained a test curl command with the AI key in it.
  • /dev/storage/logs/laravel.log was served too, with stack traces and internal paths.

The rule had no exception for dotfiles, and there was no Require all denied for them anywhere in the copy. One line written to fix a 403 opened the whole folder.

🤖 Who found it

The access logs showed no person, only automated scanners. Every successful read came from addresses in big public cloud ranges, using a mix of crawler and generic User-Agents. Some reads were single requests. Twice, one scanner fired about ten path variants in about ten seconds (/.env, /dev/.env, /.env.bak and similar), which is the usual signature of a secrets-harvesting tool.

The dev. subdomain did not appear in any public certificate transparency log. The scanners simply tried common names such as dev, staging and test. Keeping a hostname out of DNS listings and certificates hides nothing from tools that guess.

The first misuse of the AI key came several days after one of these bursts. Its usage showed models and features the application never calls, consistent with an AI coding tool running on someone else's account. Roughly 98% of that month's AI bill was not the owner's.

💸 Why only the AI key showed up

This is the main lesson of the case. Of all the secrets in the file, only the AI key has a usage counter that sends you a bill. The others leave no trace you would notice:

  • Database password: only useful if the database server is reachable. Here it sat on a private network address, but whether other hosts inside the provider's network could connect is something only the hosting provider can see.
  • Mail password: logins to the mailbox are logged by the mail provider, not by the application.
  • Live payment key: misuse shows up only in the payment dashboard, and only if someone looks.
  • APP_KEY: lets an attacker forge Laravel's encrypted cookies and session data. It leaves nothing in the database.

When one secret from a file is abused, assume every secret in that file is known.

🔍 Measuring the exposure without printing a secret

The owner needed three answers: how the leak happened, whether anyone got further in, and what to change. Every check was read-only, and no secret value was written into notes, reports or terminal output.

1. Byte size as a fingerprint. Every 200 response for a .env path in the access logs had exactly the same size as the dev/.env file on disk. That one number linked each log line to that one file, without opening the file.

2. Hashes instead of values. The dev and production .env files were compared variable by variable, using a short hash of each value. Every secret matched, so the dev copy had leaked production credentials, not test ones.

3. Production ruled out. The logs held several hundred requests for the production /.env over the same period. Not one returned content. They were redirects, 404s, rate limits and one error page with no data in it. The only leak channel was the /dev folder.

4. Code integrity over time. Three snapshots of the code, from before, during and after the exposure, were compared file by file. Every difference matched a known deployment or a dependency update. No web shells, no unknown files, no suspicious functions.

5. The database. Several database dumps from before and after the key abuse were compared, plus the live database read-only. The checks covered users, roles, tokens, sessions and the activity log, and found no triggers, stored procedures, scheduled events or unknown migrations.

6. Public exposure. Search engines, certificate transparency, URL scanners and leak indexes showed no public copy of the file or its values.

The result was "no evidence of further access", which is not the same as "nothing happened". The web logs went back only about ten weeks, while the folder had been public for more than a year. The logs held no request bodies and masked the last part of each IP. Direct database connections and mailbox logins are visible only to the provider. A good report says where its view ends.

🔁 Fixing it in the right order

  1. Stop the leak. Block dotfiles in every vhost and folder, and remove the dev copy. Check from outside that /.env and /dev/.env no longer return content.
  2. Rotate every secret that was in the file, not only the one that was abused. Database and mail passwords, the payment key, the CAPTCHA secret, the AI key and APP_KEY. When you rotate APP_KEY, do not add the old key to APP_PREVIOUS_KEYS, because a leaked key must not stay valid for decryption. Everyone gets logged out once, which is the point.
  3. Look for other copies of the old file. In this case the hosting provider's restore folder contained a full copy of the old site, including the old .env. So did an offsite backup archive. Delete them or lock them down.
  4. Fix the structure. Point the document root at public/, so that .env, storage/ and vendor/ are outside the web root. Give development its own environment, its own secrets and its own database, away from the production host.
  5. Add limits to the secrets that have a meter. Name each AI key, give it an expiry, and set a spending limit with a usage alert. Turn off automatic credit top-ups.

The fix that stops this kind of leak from coming back is a single block in the .htaccess at the web root of each vhost:

<FilesMatch "^\.">
    Require all denied
</FilesMatch>

🔎 Check your own server (read-only)

These commands only read. Replace the paths and example.com with your own, and only send HTTP requests to sites you own.

1. Find every .env on the server. Pay attention to copies in folders called dev, old, backup, test or staging.

find /var/www /home -maxdepth 6 -name '.env*' -type f \
-not -path '*/vendor/*' -not -path '*/node_modules/*' \
-printf '%TY-%Tm-%Td %8s %p\n' 2>/dev/null | sort

2. Find the risky "serve existing files" rule. The grep skips the negated !-f form, which is safe. A match is fine inside a real public/ folder, and dangerous anywhere that also holds .env.

grep -rnE --include='.htaccess' 'REQUEST_FILENAME\}\s+-f' /var/www /home 2>/dev/null

3. Ask your own sites from outside. This prints only the status and size. The response body is thrown away and never saved.

for h in example.com dev.example.com staging.example.com; do
for p in /.env /dev/.env /.env.bak /.bash_history /storage/logs/laravel.log /.git/config; do
printf '%s %s%s\n' "$(curl -s -o /dev/null -m 10 -w '%{http_code} %{size_download}' "https://$h$p")" "$h" "$p"
done
done

Anything other than 403 or 404 with a small size needs a closer look.

4. Search the access logs for successful .env reads. Then compare the response size with the real file size from stat -c %s .env. A match means the file itself was served.

awk '$9 == 200 && $7 ~ /\.env|bash_history|laravel\.log/ {print $4, $1, $7, $10}' \
/var/log/apache2/*access*.log 2>/dev/null | sort | uniq -c | sort -rn | head -50

5. Compare two .env files without showing values. Equal short hashes mean equal values.

for f in /var/www/app/.env /var/www/app/dev/.env; do
echo "== $f"
grep -E '^[A-Z0-9_]+=' "$f" | while IFS='=' read -r k v; do
printf '%-28s %s\n' "$k" "$(printf '%s' "$v" | sha256sum | cut -c1-12)"
done
done

6. Look for shell histories inside web folders. This lists file names only.

find /var/www /home -maxdepth 6 -name '.*_history' -type f -size +0 \
-printf '%s %p\n' 2>/dev/null

🧾 Signs of this kind of exposure

  • Access log lines with GET /dev/.env or GET /.env, status 200, and a response size equal to your real .env
  • Bursts of about ten secret-file paths in about ten seconds from one cloud address
  • Successful reads of .bash_history, storage/logs/laravel.log or .git/config
  • AI API usage on models or features your application never calls
  • A subdomain such as dev., staging. or old. that still answers, but that nobody on the team uses

💡 Takeaways for defenders

  • The bill shows only the secret that has a meter. When one key from a file is abused, rotate everything in that file.
  • Copies inherit nothing but the risk. A dev copy with production secrets is production, without any of the protection.
  • One "fix the 403" rule can undo the whole layout. Serve-if-exists rules belong only inside public/.
  • Hidden hostnames aren't hidden. Scanners guess dev. and staging., so they don't need certificates or DNS listings to find them.
  • Measure without exposing. Byte sizes and hashes prove what leaked without one more copy of a secret.

❓ FAQ

Our production .env returns 404. Are we safe?

Not necessarily. Check every copy of the application on the server, including dev, staging, old and backup folders and other subdomains, and the hosting provider's restore folders. In this case production was never exposed. The copy was.

Is changing the leaked API key enough?

No. Everything in the same file must be treated as known: database and mail passwords, payment and CAPTCHA keys and APP_KEY. Rotate them all, and do not keep the old APP_KEY in APP_PREVIOUS_KEYS.

Why rotate APP_KEY if nothing was stolen from the database?

APP_KEY signs and encrypts Laravel cookies and session data. With it, an attacker can forge them. Rotating it makes every forged or stolen cookie useless. The cost is that every user has to log in once more.

Is a FilesMatch rule for dotfiles enough on its own?

It stops this exact leak, and it belongs on every vhost. The durable fix is a document root set to public/, so that the project's private files are never inside the web root at all.

How do I prove what leaked without spreading the secrets again?

Use metadata. Compare response sizes in the access logs with the file size on disk, and compare short hashes of values between files. Never paste values into tickets, chats or reports.