log in
consulting hosting industries the daily tools about contact

The nginx try_files Mistake Hiding in Every Laravel Config

The most-copied nginx config snippet for Laravel has a subtle try_files ordering bug that can expose your app internals. Here's what it does and how to fix it.

The most-copied nginx config snippet for Laravel has a security problem in it. It's been copy-pasted from Stack Overflow, DigitalOcean tutorials, and the Laravel docs themselves for years. Most people running it have no idea it's there.

I didn't catch it myself until a pentest report came back on a client site and flagged it. That was embarrassing. Let me save you the same moment.

What the Config Actually Does

Here's the canonical Laravel nginx config you'll find everywhere:

location / {
    try_files $uri $uri/ /index.php?$query_string;
}

The intent is straightforward: try to serve the file as-is, then try it as a directory, and if neither works, hand everything off to Laravel's index.php front controller. Laravel then routes it, applies middleware, checks auth, does everything you wrote application code to do.

The problem is in the first two directives: $uri and $uri/.

If a file physically exists on disk at that path, nginx serves it directly - without ever touching PHP, without ever touching Laravel, without middleware, without auth checks. The file just goes out the door.

Why That's a Problem in Practice

On a freshly deployed Laravel app the public directory is clean. But think about what can accumulate in public/ over the lifetime of a real project:

  • public/.env - if someone accidentally ran php artisan from the wrong directory and it dropped a copy there
  • public/phpinfo.php - a developer left it for debugging and forgot to remove it
  • public/storage - symlinked to storage/app/public, which might have user-uploaded files with predictable names
  • public/telescope - if you forgot to gate Telescope properly and the assets are there
  • Build artifacts, old deployment scripts, .git directories if someone deployed by cloning

nginx will serve every single one of those without asking Laravel anything. No middleware. No auth middleware. No IP allowlisting you put in a route group. None of it.

The pentest I mentioned found a phpinfo.php in public/ on a staging server. The site was behind an auth middleware on every route. The developer assumed that protected everything. It didn't. nginx handed the file straight out and the phpinfo output exposed the server's internal IP, PHP version, loaded extensions, and environment variable names (not values, but names are still useful to an attacker).

The Real-World Scope of This

I've audited maybe a dozen client servers over the past few years specifically looking for this. The $uri hit before the fallback isn't always wrong - you need it for CSS, JS, images, fonts. The issue is what else ends up in public/ that you didn't intend to be public.

The storage symlink is the one that keeps me up at night. php artisan storage:link creates public/storage -> ../storage/app/public. If your app stores user uploads there and you're not careful about filename entropy, those files are directly accessible via nginx whether or not your Laravel routes gate them. I've seen healthcare-adjacent clients store generated PDFs with sequential numeric names. public/storage/reports/1042.pdf, public/storage/reports/1043.pdf. You see where this goes.

The Fix

There are a couple of approaches depending on what you actually need.

Option 1: Tighten the location block to only serve known static types

# Serve legitimate static assets directly
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|woff|woff2|ttf|eot|map)$ {
    expires 1y;
    access_log off;
    add_header Cache-Control "public, immutable";
    try_files $uri =404;
}

# Everything else goes through PHP
location / {
    try_files $uri /index.php?$query_string;
}

Note I dropped $uri/ from the fallback location too. Directory listing should be off (and nginx disables it by default), so $uri/ resolving to a directory just results in a 403 or a redirect to index.html inside that directory. You probably don't want nginx auto-resolving directories on a Laravel app. Drop it.

Option 2: If you want to keep try_files simpler, at least add explicit denies for dangerous paths

# Block direct access to anything that looks like a config or script
location ~* \.(env|log|sh|sql|bak|git)$ {
    deny all;
    return 404;
}

location ~ /\.(?!well-known) {
    deny all;
}

location / {
    try_files $uri $uri/ /index.php?$query_string;
}

This is defense in depth rather than a root fix. I prefer Option 1 because it's explicit about what's allowed rather than trying to enumerate what's denied. Allowlists beat denylists.

Option 3: Move everything out of public that shouldn't be public

This is the real fix. Audit your public/ directory. Remove phpinfo.php. Make sure your .env is in the project root (one level above public/), not inside it. For the storage symlink issue, either:

  • Store sensitive files outside of storage/app/public entirely and serve them through a Laravel route with auth checks
  • Generate files with UUIDs or signed URLs rather than sequential IDs
// Instead of storing to public disk with predictable names:
Storage::disk('public')->put('reports/' . $report->id . '.pdf', $pdf);

// Store on a private disk and serve through a signed URL:
Storage::disk('private')->put('reports/' . $report->id . '.pdf', $pdf);

// Then in your controller, with auth middleware on the route:
public function download(Report $report)
{
    $this->authorize('view', $report);
    return Storage::disk('private')->download('reports/' . $report->id . '.pdf');
}

This way even if nginx config is misconfigured, the file isn't reachable directly because it's not in a web-accessible location at all.

The Gotcha Inside the Gotcha

Here's something that bit me specifically: the location block ordering in nginx is not always what you think it is.

If you add the static-type regex block from Option 1 but put it after a broad location / block in your config, the broad block can win depending on how specific the match is. nginx processes location blocks with exact matches first (=), then longest prefix matches, then regex matches (in order of appearance). A location / is a prefix match that matches everything. A location ~* is a regex match. Regex blocks take priority over prefix blocks - but only if they match. If you misconfigure the regex, the prefix block silently wins.

Always test with curl and check response headers:

curl -I https://yourdomain.com/test.php
# Should return 404, not 200

curl -I https://yourdomain.com/.env
# Should return 404, not 200 or 403

curl -I https://yourdomain.com/storage/some-file.pdf
# Depends on your setup - but know what you expect

Don't assume. Verify.

When I Reach for Which Approach

For new Laravel projects I'm setting up from scratch, I use Option 1 with the explicit static-type allowlist plus proper private disk storage for anything sensitive. It's a bit more config to write once but it's the cleanest model.

For existing client servers where I'm auditing rather than rebuilding, I start with Option 2 (explicit denies) because it's lower risk to add on top of a working config, then work through the storage audit separately. Changing the try_files behavior on a production app that's been running for three years is a change I want to test thoroughly before pushing.

I wouldn't skip the storage audit in either case. nginx config is one layer. Not putting sensitive files in web-accessible locations is the layer that actually matters.

Closing

This isn't a exotic vulnerability. It's a gap between what developers think their application layer is protecting and what the web server is actually doing before the application sees the request. The fix is ten lines of nginx config and a storage audit. Do it before someone else finds it for you.

Related

Need help shipping something like this? Get in touch.