Skip to content

Secure a Laravel App Before Production: Tested Checklist

Tested Laravel 13 production security checklist: debug mode, .env exposure, proxies, headers, CSRF, throttling, mass assignment, IDOR and uploads.

Dasun Kalupahana 12 min read 20 views 0 comments
On this page
  1. What Laravel handles for you, and what it does not
  2. 1. Turn off debug mode and set the environment
  3. 2. Point the web server at public/, never the project root
  4. 3. Protect APP_KEY and plan for rotating it
  5. 4. Cache config in production, and stop calling env() outside config files
  6. 5. Force HTTPS and secure cookies
  7. 6. Trust proxies precisely, never blindly
  8. 7. Trust your own hostnames
  9. 8. Send security headers
  10. 9. Keep CSRF protection on
  11. 10. Harden login: hashing, throttling, sessions
  12. 11. Authorize every record access (the IDOR check)
  13. 12. Block mass assignment
  14. 13. Never interpolate into SQL, never print raw HTML
  15. 14. Treat uploads as hostile
  16. 15. Audit dependencies and keep patching
  17. Infrastructure items I did not test
  18. Pre-launch checklist
  19. Common mistakes
  20. Related Dev Talk articles and tools
  21. Frequently asked questions
  22. Is Laravel secure by default?
  23. What is the difference between APP_ENV and APP_DEBUG?
  24. Do I need all of these if I'm behind Cloudflare or a load balancer?
  25. Should staging have debug mode off too?
  26. How often should I run composer audit?

Last updated: 8 November 2026. See the update log at the end.

Laravel gives you safe defaults for a lot of things: Blade escapes output, the query builder uses bound parameters, forms need a CSRF token, and session IDs change on login. But a handful of protections only exist if you set them up, and the dangerous ones fail silently. Nothing breaks when debug mode is on, when the web server points at the wrong folder, or when a user can open someone else's record by changing a number in the URL. The app just works, until someone finds it.

This article is a pre-launch checklist where every item was reproduced against a real Laravel 13.40.0 app over HTTP, so you see the actual requests, responses and numbers, including the defaults that are not safe. For each item you get the problem, the evidence, the fix, and how to check it afterwards.

What Laravel handles for you, and what it does not

RiskOut of the boxYour job
XSS in Blade{{ }} escapesNever use {!! !!} on user input
SQL injectionBound parameters in the query builder and EloquentNever interpolate variables into raw SQL
CSRFWeb routes reject a missing token with 419Be careful with exclusions
Session fixationSession ID regenerated on loginNothing
Debug outputEnabled in the default .env fileTurn it off in production
Mass assignmentThe skeleton's User model whitelists fieldsYour own models and request handling
AuthorizationNothing automaticEvery route that loads a record
Brute-force protectionThrottle middleware is available but not applied to your routesAdd it
Security headersNone sentAdd a middleware or set them at the web server
Proxy and host trustForwarded headers ignored by defaultConfigure it correctly for your setup

1. Turn off debug mode and set the environment

A fresh Laravel app's .env has APP_ENV=local and APP_DEBUG=true. In production:

APP_ENV=production
APP_DEBUG=false

I made a route that throws an exception and requested it both ways:

APP_DEBUG=true:   status 500, 842,814 bytes, 110 references to absolute server paths,
                  stack trace with source code
APP_DEBUG=false:  status 500, 6,592 bytes, a generic "Server Error" page

The debug page told an attacker my directory layout, the framework internals and application code around the failure. I did not find APP_KEY or database passwords in that page's text in this test, but file paths and code are already more than anyone should get from an error. Debug mode on staging servers that are reachable from the internet counts too. More on this in 5 Laravel production mistakes that pass every local test.

2. Point the web server at public/, never the project root

Only the public folder should be reachable from the web. I served the project root instead of public and requested /.env:

document root = project root:  GET /.env  ->  status 200, file contents returned (APP_KEY included)
document root = public/:       GET /.env  ->  status 404

A web server pointed at the wrong folder serves your secrets to the world. In Nginx, set the root to public and refuse dotfiles as a second line of defense. This follows the structure of the standard Laravel Nginx config; I did not run Nginx for this article:

server {
    root /var/www/app/current/public;
    index index.php;

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

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

Check it from outside after every infrastructure change:

curl -s -o /dev/null -w "%{http_code}\n" https://your-site.example/.env
# expect 404 or 403, never 200

3. Protect APP_KEY and plan for rotating it

The APP_KEY encrypts cookies, sessions (if you encrypt them) and anything you pass through Crypt. The skeleton's .gitignore already ignores .env, .env.backup and .env.production; keep it that way, and keep keys out of build logs and chat messages. The skeleton's session config sets 'serialization' => 'json', and its comment explains that this guards against "gadget chain" attacks if the key leaks.

If a key does leak, you need to replace it without logging everyone out or breaking stored encrypted data. Laravel reads old keys from APP_PREVIOUS_KEYS (comma separated) in config/app.php. I tested the mechanism with the framework's encrypter:

new key only:                  DecryptException: The MAC is invalid.
new key + previousKeys([old]): session-data
php artisan key:generate --show      # prints a new key without writing it
# then in .env:
APP_KEY=base64:NEW_KEY...
APP_PREVIOUS_KEYS=base64:OLD_KEY...

The --show option is listed in the command's help ("Display a new key instead of modifying files"). Remove old keys once the data encrypted with them has expired or been re-encrypted. For storing the file itself, php artisan env:encrypt and env:decrypt exist in 13.40 (I listed them but did not run them), which lets you commit an encrypted environment file and keep the decryption key elsewhere.

4. Cache config in production, and stop calling env() outside config files

This is partly a performance item and partly a security one, because it changes where your settings really come from. With the config cached, env() outside config/*.php no longer reads .env:

before config:cache:  {"env":"Laravel","config":"Laravel"}
after config:cache:   {"env":null,"config":"Laravel"}

Code like if (env('APP_DEBUG')) in a controller then silently gets null. Read settings with config(), and put every env() call in a config file. On deploy:

php artisan optimize
php artisan about | grep -E "Environment|Debug Mode|Config|Routes"
Environment ..................................................... production
Debug Mode ............................................................. OFF
Config .............................................................. CACHED
Routes .............................................................. CACHED

That output is from my test app with production settings. about is a fast way to confirm what a server is actually running.

5. Force HTTPS and secure cookies

The default session cookie is HttpOnly with SameSite=Lax, but it has no Secure flag, so a browser will send it over plain HTTP too. This is what the default response sends (values removed):

Set-Cookie: XSRF-TOKEN=...; path=/; samesite=lax
Set-Cookie: laravel-session=...; path=/; httponly; samesite=lax

With SESSION_SECURE_COOKIE=true in .env:

Set-Cookie: XSRF-TOKEN=...; path=/; secure; samesite=lax
Set-Cookie: laravel-session=...; path=/; secure; httponly; samesite=lax

The XSRF-TOKEN cookie is not HttpOnly by design, so JavaScript can read it for AJAX requests. Redirect HTTP to HTTPS at the load balancer or web server, and send an HSTS header (see item 8). Setting the secure flag before your site is fully on HTTPS locks out your own sessions on HTTP, so do the two together.

6. Trust proxies precisely, never blindly

Behind a load balancer, the app must read the real client IP and scheme from X-Forwarded-* headers, but only from your proxy. By default Laravel ignores them, which is safe but means isSecure() is false and every request appears to come from the proxy's IP. The tempting fix is trustProxies(at: '*'). I tested all three configurations, sending forged X-Forwarded-Proto and X-Forwarded-For headers, then eight wrong logins (each with a different forged IP) against a route limited to five attempts per minute:

ConfigurationForged headers accepted?8 login attempts with different forged IPs
Default (none)No: isSecure=false, ip=127.0.0.1422 422 422 422 422 429 429 429
at: '*'Yes: isSecure=true, ip=1.2.3.4422 422 422 422 422 422 422 422 (throttle bypassed)
at: '10.0.0.0/8' (not the sender)No422 422 422 422 422 429 429 429
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(at: '10.0.0.0/8');   // your load balancer's address range
})

With at: '*' any client could pretend to be any IP, which defeats IP-based throttling and corrupts your logs. Using '*' is only reasonable when the app can be reached exclusively through the proxy, for example inside a private network where the proxy is the only way in. Otherwise list your proxy's IP or CIDR range. The method also takes an array, which I did not test.

7. Trust your own hostnames

Laravel builds URLs from the request's Host header unless told otherwise. A password-reset email that uses url() or route() can then link to an attacker's domain if they send a forged Host. I requested a route that returns url('/reset-password/abc123'):

curl -s -H "Host: evil.example" http://127.0.0.1:8300/host
{"host":"evil.example","url":"http://evil.example/reset-password/abc123", ...}

The fix is trustHosts:

$middleware->trustHosts(at: ['^app\.example\.com$']);
APP_ENV=production, Host: evil.example     -> status 400
APP_ENV=production, Host: 127.0.0.1:8300   -> status 200 (an allowed host)

One trap I hit: the middleware does nothing when APP_ENV=local. The framework's shouldSpecifyTrustedHosts() returns false in the local environment and in unit tests, so in my first attempt the forged host still got a 200. Test it with production settings, or you will believe it works when it does not.

8. Send security headers

Laravel sends none. A small middleware covers the common ones:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class SecurityHeaders
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        $response->headers->set('X-Content-Type-Options', 'nosniff');
        $response->headers->set('X-Frame-Options', 'DENY');
        $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
        $response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');

        if ($request->isSecure()) {
            $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
        }

        return $response;
    }
}
// bootstrap/app.php
$middleware->append(\App\Http\Middleware\SecurityHeaders::class);
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()

HSTS only appears when the request is seen as secure, so it depends on item 6: behind an HTTPS-terminating proxy that you have not trusted, isSecure() is false and the header is never sent (I confirmed both outcomes in the table above, with HSTS present only under at: '*'). Start with a short max-age while you test, because browsers remember HSTS. A Content-Security-Policy is the strongest header against XSS, but it needs tuning to your scripts and styles, so I left it out of this generic example.

The test server also sent X-Powered-By: PHP/8.3.6. Set expose_php = Off in php.ini to stop advertising the version (standard PHP setting, not tested here).

9. Keep CSRF protection on

In Laravel 13.40 the web middleware group includes PreventRequestForgery, and the older VerifyCsrfToken class is a deprecated subclass of it. A POST without a token:

curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8300/ping
# 419

If you must exclude a route, such as a payment provider's webhook, do it narrowly, and verify the sender some other way (a signature check), because an excluded route has no CSRF protection at all:

$middleware->preventRequestForgery(except: ['webhooks/stripe']);

The method also has originOnly and allowSameSite options in the 13.40 source; read their docs before changing them. If real users hit 419 errors, see Laravel 419 Page Expired: causes and fixes rather than turning the protection off.

10. Harden login: hashing, throttling, sessions

Hashing. The skeleton sets BCRYPT_ROUNDS=12. Cost doubles with each extra round, so measure on your hardware. On this machine:

bcrypt rounds=10:   51 ms
bcrypt rounds=12:  198 ms
bcrypt rounds=14:  796 ms

Those are single runs; the pattern (about 4 times slower per two rounds) is what matters. Pick the highest cost your login endpoint can afford under load, and use Hash::needsRehash() at login to upgrade old hashes as users sign in. PHP here also offered argon2i and argon2id.

Throttling. Login routes need a rate limit. The built-in middleware worked as expected on a route with throttle:5,1:

attempt 1-5 -> 422  X-RateLimit-Limit: 5  X-RateLimit-Remaining: 4 ... 0
attempt 6   -> 429  Retry-After: 59

For guests that limit is keyed by IP address, which is why item 6 matters. A more targeted limiter keys on the account as well. This is the standard form from the Laravel documentation, not something I ran:

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('login', function (Request $request) {
    return Limit::perMinute(5)->by(strtolower($request->input('email')).'|'.$request->ip());
});

// Route::post('/login', ...)->middleware('throttle:login');

For API limits and algorithms in depth, see API rate limiting strategies.

Sessions. Auth::attempt() regenerates the session ID, which blocks session fixation. I checked it in the sessions table: before login the newest row had a null user_id; after login the ID was different and user_id was set. In the source this is $this->session->regenerate(true) in SessionGuard::updateSession(). If you write your own login code, call $request->session()->regenerate() yourself.

11. Authorize every record access (the IDOR check)

Authentication tells you who the user is. Authorization decides what they may touch. Route model binding fetches any record, for anyone, unless you check. I logged in as alice and requested bob's record on two routes:

// No check: any logged-in user can read any user's email
Route::get('/users/{user}/email-unsafe', fn (User $user) => $user->email)
    ->middleware('auth');

// With an ownership check
Route::get('/users/{user}/email-safe', function (User $user) {
    Gate::allowIf(fn ($auth) => $auth->is($user));
    return $user->email;
})->middleware('auth');
alice reads bob, no check:      bob@example.test
alice reads bob, ownership check: status 403
alice reads herself:             alice@example.test

For real apps, use policies ($this->authorize('view', $invoice) or Gate::authorize()) and write a test per resource that logs in as a second user and expects a 403. This is the bug most likely to survive review, because no error ever appears.

12. Block mass assignment

Passing $request->all() into create() lets a client set any column, including ones your form never showed. Laravel 13's skeleton User model whitelists fields with an attribute:

#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable { /* ... */ }

I compared that with an unguarded model on the same table (protected $guarded = [];), posting is_admin=1 to both:

// Vulnerable
$u = LooseUser::create($request->all());

// Safer: validate, and use only validated data on a model that whitelists fields
$data = $request->validate([
    'name' => 'required|string|max:100',
    'email' => 'required|email|unique:users',
    'password' => 'required|min:12',
]);
$u = User::create($data);
unguarded model + $request->all():       {"is_admin":1}
validated data + whitelisted model:      {"is_admin":0}

Treat $guarded = [] as a bug unless the data is never from a request. In development, Model::shouldBeStrict() turns silently discarded attributes into exceptions (see the performance article for how it behaves).

13. Never interpolate into SQL, never print raw HTML

SQL. Bound parameters are the defense. Here are two raw queries, one with the input pasted into the string and one with a placeholder, given the classic payload ' OR '1'='1:

// Vulnerable
DB::select("select id, name from users where name = '{$request->query('name')}'");

// Safe
DB::select('select id, name from users where name = ?', [$request->query('name')]);
interpolated: [{"id":1,"name":"alice"},{"id":2,"name":"bob"}]   (every row returned)
bound:        []

The same rule applies to whereRaw(), selectRaw() and orderByRaw(). Bindings protect values, not column names: if a user chooses the sort column, check it against an allow-list. See Eloquent vs Query Builder for when each fits.

XSS. I rendered <script>alert(1)</script> both ways:

{{ $q }}     -> <p>escaped: &lt;script&gt;alert(1)&lt;/script&gt;</p>
{!! $q !!}   -> <p>raw: <script>alert(1)</script></p>

Use {!! !!} only for HTML you generated yourself. If users may submit HTML, run it through a sanitizer first (I did not test one) and add a Content-Security-Policy.

14. Treat uploads as hostile

Never trust the file extension or the client's content type. I uploaded a real PNG, a PHP script renamed evil.jpg (sent with Content-Type: image/jpeg) and a file named evil.php, against this validation:

$request->validate([
    'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png', 'max:2048'],
]);
$path = $request->file('avatar')->store('avatars');
real.png  -> {"stored":"avatars/JNQgygKEpFyrUOQkyojulvOi4gEUyGBJvg1fq290.png"}
evil.jpg  -> "The avatar field must be an image." / "must be a file of type: jpg, jpeg, png."
evil.php  -> the same two errors

Laravel checks the file's real content, so renaming does not pass. Two more details from the result: the stored name is random (store() generates it, so never use the client's filename), and the file landed in storage/app/private/avatars/, outside the web root. Keep uploads on a private disk and serve them through a controller or signed URLs unless they must be public. Also set a request size limit at the web server and in PHP (upload_max_filesize, post_max_size), which I did not test.

15. Audit dependencies and keep patching

composer install --no-dev --optimize-autoloader   # no dev tools on the server
composer audit                                      # check installed packages against known advisories

The composer audit command is present in Composer 2.8.12 here. I could not run it, because it fetches advisories from Packagist, which this environment cannot reach. Run it in CI on every build and on a schedule, since new advisories appear for code you have not changed. Keep the framework current within your major version (this article's tests used 13.40.0), and read the release notes of security releases. If you are on Laravel 12, see the 12 to 13 upgrade guide.

Infrastructure items I did not test

These matter just as much and live outside the Laravel app, so verify them in your own environment:

  • The database, Redis and any queue backend should not be reachable from the internet. Bind to private addresses and use a firewall or security group.
  • Use separate database users for the app and for migrations, with the least privileges that work.
  • Only storage and bootstrap/cache need to be writable by the web server user.
  • Keep backups, and test restoring one.
  • Do not log passwords, tokens or full request bodies.

Pre-launch checklist

#CheckHow to verify
1APP_ENV=production, APP_DEBUG=falsephp artisan about
2Document root is public/curl -I https://site/.env returns 404 or 403
3Key not in git, rotation plan existsCheck .gitignore, note APP_PREVIOUS_KEYS
4Config cached, no env() outside configphp artisan optimize, search for env( in app/
5HTTPS redirect and secure cookiesSESSION_SECURE_COOKIE=true, inspect Set-Cookie
6Trusted proxies set to your proxy onlyForged X-Forwarded-For does not change $request->ip()
7Trusted hosts setForged Host gets a 400 in production mode
8Security headers presentcurl -sI https://site | grep -iE "strict-transport|x-frame|x-content"
9CSRF on, exclusions justifiedPOST without a token returns 419
10Login throttledSixth bad attempt returns 429
11Policies on every record routeA second-user test expects 403
12No $guarded = [] with request dataSearch for guarded and $request->all()
13No interpolated raw SQL, no raw Blade outputSearch for Raw(, DB::select(" and {!!
14Uploads validated and stored privatelyUpload a renamed script and expect a validation error
15composer audit clean in CICI job output

Common mistakes

  • Checking security only in the local environment. Trusted hosts does nothing in local, and debug is on by default there.
  • Using trustProxies(at: '*') because it makes HTTPS detection work. It also makes IP addresses forgeable.
  • Authenticating but not authorizing. middleware('auth') only proves someone is logged in.
  • Disabling CSRF globally to fix a 419. Find the cause instead.
  • Validating by file extension. Use content-based rules and a private disk.
  • Relying on a WAF or CDN instead of fixing the app. They reduce noise; they do not repair an IDOR.
  • Committing .env.production. The skeleton ignores it for a reason.

Tools: composer audit, php artisan about, and curl -I for checking headers and exposed files from outside. A static analyzer such as PHPStan or Larastan and a scanner like OWASP ZAP help too; I did not run them for this article.

Frequently asked questions

Is Laravel secure by default?

For the common injection and CSRF cases, yes, as the tests above show. It is not secure by default in the areas that depend on your configuration and your code: debug mode, web server layout, proxy and host trust, throttling, headers and authorization.

What is the difference between APP_ENV and APP_DEBUG?

APP_DEBUG controls whether detailed errors are shown. APP_ENV names the environment, and some framework behavior depends on it, such as trusted hosts, which is skipped in local. Set both.

Do I need all of these if I'm behind Cloudflare or a load balancer?

Yes. A proxy does not know your business rules, so it cannot fix a missing authorization check, mass assignment or unsafe SQL. It also makes the proxy-trust settings more important, not less.

Should staging have debug mode off too?

If it is reachable from the internet, yes, and it should not contain real customer data.

How often should I run composer audit?

On every CI build, plus a scheduled run (daily or weekly), because advisories are published for code you did not change.

Free tools for this topic

Code snippets and fixes for this topic

Get one practical tip every week

New tutorials, fixes and free cheat sheets from DEV Talk, straight to your inbox. No spam, unsubscribe in one click.

Have a question about this post?

Ask the community: other developers and the author can answer.

Ask the community

Get notified about new articles

One click, no email needed. Turn it off anytime with the bell at the top.

Get new articles on your iPhone

Add DEV Talk to your Home Screen, open it, then tap the bell.

Rate this post
0.0
Share

Enjoying the content?

If this article helped you, you can support the author with a coffee. It keeps new posts coming.

Buy me a coffee (opens in a new tab)

Comments 0

Be kind. Comments with links are reviewed before they appear.

No comments yet

Be the first to share your thoughts.

Get new articles in your inbox

Practical tutorials and deep dives, about once a week. No spam, unsubscribe anytime.

Privacy policy

Add DEV Talk to your Home Screen

  1. 1 Tap the Share button in Safari's toolbar.
  2. 2 Scroll down and tap Add to Home Screen.
  3. 3 Tap Add. Open it from the Home Screen to read like an app and to turn on notifications with the bell.

We use cookies to improve your experience and analyze traffic. By continuing, you agree to our Privacy Policy.