Blog / Hardening
Hardening · 6 min read · May 20, 2026

Turning off the footguns: production config for Laravel

Dense network cable infrastructure

Most Laravel breaches we investigate did not start with clever exploitation. They started with a setting: debug mode left on, an .env one HTTP request away, a session cookie that worked over plain HTTP, an ops dashboard with no gate. Configuration is the part of security that no test suite covers and no code review sees — and AI-assisted projects are especially prone to shipping with development settings intact, because the assistant's job ended when the app ran locally.

Here are the footguns, in the order we check them on a real audit, each with the value to set and a way to verify it from outside.

1. APP_DEBUG and APP_ENV

With APP_DEBUG=true, any unhandled error renders a debug page: stack trace, request payload, headers, and enough application internals to plan a real attack. It is the single worst toggle to get wrong.

# .env (production) APP_ENV=production APP_DEBUG=false

Verify on the server with php artisan about (look for Debug Mode: OFF), then from outside by requesting a route that throws — users must see your error page, never a trace. While you are there: set APP_URL to the real HTTPS origin; signed URLs and emails depend on it.

2. The .env file itself

Two separate failure modes. First, the web server: the document root must be the public/ directory, not the project root. Get that wrong and https://yourapp.com/.env serves your database password and API keys as plain text — automated scanners probe every host on the internet for exactly this path, constantly.

curl -s -o /dev/null -w "%{http_code}\n" https://yourapp.com/.env # 404/403 = good

Second, version control: .env must be in .gitignore and must never have been committed. History counts — check git log --all --oneline -- .env, and if it was ever in there, rotate every secret it contained. More on secret-scanning in the pre-launch checklist.

3. APP_KEY discipline

The application key encrypts sessions, cookies and anything you pass through Crypt. Three rules: generate it once per environment (php artisan key:generate), never share it between staging and production, never commit it. If it leaks, attackers can forge cookies and decrypt stored values; rotate it deliberately, knowing existing encrypted data and sessions invalidate.

4. Session and cookie flags

The defaults are decent; production wants them explicit:

# .env (production) SESSION_DRIVER=database # or redis — not file, on multi-server setups SESSION_SECURE_COOKIE=true # cookie never sent over plain HTTP SESSION_HTTP_ONLY=true # default; JS cannot read the cookie SESSION_SAME_SITE=lax # default; blocks classic cross-site POSTs

SESSION_SECURE_COOKIE is the one assistants and tutorials omit, because true breaks local HTTP development — so it stays false forever. Set it in the production env file, not in code. If the session cookie can travel over HTTP once, a network attacker owns the session.

5. HTTPS, HSTS and the proxy

Terminate TLS, redirect all HTTP to HTTPS at the web server or load balancer, and send HSTS so browsers stop trying HTTP at all:

# nginx, inside the HTTPS server block add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

Behind a load balancer or Cloudflare, tell Laravel to trust the proxy headers, or it will think requests are HTTP and mark cookies/URLs accordingly:

// bootstrap/app.php (Laravel 11+) ->withMiddleware(function (Middleware $middleware) { $middleware->trustProxies(at: '*'); // scope to your LB's IPs when you can })

6. CORS that actually says something

The reflex fix for a CORS error is 'allowed_origins' => ['*'] in config/cors.php — and AI assistants apply it instantly. On a cookie-authenticated app that is an invitation: list your real origins instead, keep supports_credentials off unless you genuinely do cross-origin cookie auth, and remember CORS protects browsers, not your API — authorization still has to live server-side.

'allowed_origins' => ['*'], 'allowed_origins' => ['https://app.example.com', 'https://admin.example.com'],

7. Ops tooling: Telescope, Horizon, Debugbar, Pulse

Telescope shows every request, query, job and mail with payloads — a perfect breach kit if exposed. Horizon and Pulse are gentler but still map your internals. Either keep them out of production entirely (composer install --no-dev with Telescope in require-dev), or gate them: each ships an authorization gate (viewTelescope, viewHorizon, viewPulse) — restrict to specific admin emails. Debugbar should never survive --no-dev. Verify like an outsider:

curl -s -o /dev/null -w "%{http_code}\n" https://yourapp.com/telescope # 404/403 curl -s -o /dev/null -w "%{http_code}\n" https://yourapp.com/horizon # 404/403

8. Security headers

Five headers cost nothing and blunt whole bug classes — clickjacking, MIME sniffing, referrer leakage. Set them at the web server (or a small global middleware):

add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; # Content-Security-Policy: start with Report-Only and tighten over time

A strict CSP is a project of its own; the other four are a five-minute deploy. Check yourself afterwards with securityheaders.com.

9. Logging that helps you and not the attacker

Three settings: LOG_LEVEL no lower than info in production (debug sprays internals into files); use the daily channel (or a stack including it) so logs rotate instead of growing into one greppable mega-file of history; and route critical to a human via a Slack/webhook channel. Then audit what you log — no passwords, tokens, or full card numbers, ever. Logs are the place secrets leak after you fixed everything else.

10. The deploy ritual that locks it in

Configuration hardening only counts if every deploy reproduces it. A minimal production deploy:

composer install --no-dev --prefer-dist --optimize-autoloader php artisan migrate --force php artisan optimize # caches config, routes, views, events php artisan queue:restart # workers pick up the new code php artisan about # eyeball: production / debug OFF / caches CACHED

One Laravel-specific trap: once config is cached, env() returns null outside the config/ directory. AI-generated code loves calling env() in controllers; it works in dev, then silently breaks under config:cache — and the common "fix" is disabling the cache rather than fixing the calls. Do it properly:

grep -rn "env(" app # every hit should become a config() call backed by a config file

Copy-paste checklist

  • APP_ENV=production, APP_DEBUG=false, real APP_URL
  • Web root is public/; curl /.env returns 404/403
  • APP_KEY unique per environment, never committed
  • SESSION_SECURE_COOKIE=true, driver database/redis
  • HTTP→HTTPS redirect + HSTS; proxies trusted explicitly
  • CORS origins listed, no * on authed APIs
  • Telescope/Horizon/Pulse gated or absent; Debugbar absent
  • X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy set
  • LOG_LEVEL=info+, daily rotation, critical alerts reach a human
  • Deploys run --no-dev, optimize, queue:restart; no env() outside config

Get the config reviewed with the code

Configuration review is a standard part of our Laravel security audit — checked on the real server, not just in the repo, alongside the access-control and injection work that tools miss. See a sample report for how findings are written up.

FAQ

Is APP_DEBUG=true acceptable if the app is internal or behind a VPN?

It is still a bad habit: debug pages leak data into screenshots, logs and browser history, and "internal" apps have a way of getting exposed during migrations or DNS changes. Keep debug off anywhere real data lives and rely on proper logging instead.

Do I really need all ten for a small MVP?

Items 1–4 and 7 are non-negotiable on day one — they are the difference between an incident and a non-event, and each takes minutes. Headers, CSP and alerting polish can follow in week one. The point of the list is that none of it requires refactoring; it is configuration.

Get your production config reviewed.

Every audit checks configuration on the real server — not just in the repo.