On this page
- What Laravel handles for you, and what it does not
- 1. Turn off debug mode and set the environment
- 2. Point the web server at public/, never the project root
- 3. Protect APP_KEY and plan for rotating it
- 4. Cache config in production, and stop calling env() outside config files
- 5. Force HTTPS and secure cookies
- 6. Trust proxies precisely, never blindly
- 7. Trust your own hostnames
- 8. Send security headers
- 9. Keep CSRF protection on
- 10. Harden login: hashing, throttling, sessions
- 11. Authorize every record access (the IDOR check)
- 12. Block mass assignment
- 13. Never interpolate into SQL, never print raw HTML
- 14. Treat uploads as hostile
- 15. Audit dependencies and keep patching
- Infrastructure items I did not test
- Pre-launch checklist
- Common mistakes
- Related Dev Talk articles and tools
- Frequently asked questions
- Is Laravel secure by default?
- What is the difference between APP_ENV and APP_DEBUG?
- Do I need all of these if I'm behind Cloudflare or a load balancer?
- Should staging have debug mode off too?
- 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
| Risk | Out of the box | Your job |
|---|---|---|
| XSS in Blade | {{ }} escapes | Never use {!! !!} on user input |
| SQL injection | Bound parameters in the query builder and Eloquent | Never interpolate variables into raw SQL |
| CSRF | Web routes reject a missing token with 419 | Be careful with exclusions |
| Session fixation | Session ID regenerated on login | Nothing |
| Debug output | Enabled in the default .env file | Turn it off in production |
| Mass assignment | The skeleton's User model whitelists fields | Your own models and request handling |
| Authorization | Nothing automatic | Every route that loads a record |
| Brute-force protection | Throttle middleware is available but not applied to your routes | Add it |
| Security headers | None sent | Add a middleware or set them at the web server |
| Proxy and host trust | Forwarded headers ignored by default | Configure 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=falseI 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" pageThe 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 404A 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 2003. 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-dataphp 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 .............................................................. CACHEDThat 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=laxWith SESSION_SECURE_COOKIE=true in .env:
Set-Cookie: XSRF-TOKEN=...; path=/; secure; samesite=lax
Set-Cookie: laravel-session=...; path=/; secure; httponly; samesite=laxThe 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:
| Configuration | Forged headers accepted? | 8 login attempts with different forged IPs |
|---|---|---|
| Default (none) | No: isSecure=false, ip=127.0.0.1 | 422 422 422 422 422 429 429 429 |
at: '*' | Yes: isSecure=true, ip=1.2.3.4 | 422 422 422 422 422 422 422 422 (throttle bypassed) |
at: '10.0.0.0/8' (not the sender) | No | 422 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
# 419If 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 msThose 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: 59For 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.testFor 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: <script>alert(1)</script></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 errorsLaravel 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 advisoriesThe 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
storageandbootstrap/cacheneed 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
| # | Check | How to verify |
|---|---|---|
| 1 | APP_ENV=production, APP_DEBUG=false | php artisan about |
| 2 | Document root is public/ | curl -I https://site/.env returns 404 or 403 |
| 3 | Key not in git, rotation plan exists | Check .gitignore, note APP_PREVIOUS_KEYS |
| 4 | Config cached, no env() outside config | php artisan optimize, search for env( in app/ |
| 5 | HTTPS redirect and secure cookies | SESSION_SECURE_COOKIE=true, inspect Set-Cookie |
| 6 | Trusted proxies set to your proxy only | Forged X-Forwarded-For does not change $request->ip() |
| 7 | Trusted hosts set | Forged Host gets a 400 in production mode |
| 8 | Security headers present | curl -sI https://site | grep -iE "strict-transport|x-frame|x-content" |
| 9 | CSRF on, exclusions justified | POST without a token returns 419 |
| 10 | Login throttled | Sixth bad attempt returns 429 |
| 11 | Policies on every record route | A second-user test expects 403 |
| 12 | No $guarded = [] with request data | Search for guarded and $request->all() |
| 13 | No interpolated raw SQL, no raw Blade output | Search for Raw(, DB::select(" and {!! |
| 14 | Uploads validated and stored privately | Upload a renamed script and expect a validation error |
| 15 | composer audit clean in CI | CI 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.
Related Dev Talk articles and tools
- API rate limiting strategies
- Laravel CORS errors with a React or Vue frontend
- MySQL "Too many connections"
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.
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.
Enjoying the content?
If this article helped you, you can support the author with a coffee. It keeps new posts coming.
Comments 0
No comments yet
Be the first to share your thoughts.