The first time a Laravel app of mine landed on a security report I learned a useful lesson. The finding was not a clever zero-day. It was a $guarded = [] on a User model someone had copied from a tutorial three years earlier, plus a {!! $note !!} in an admin Blade template. Both took ninety seconds to fix. Neither was obvious from the dashboard.
That is the shape of Laravel security in practice. The framework gets the hard parts right by default — escaped Blade output, parameterised queries, hashed passwords, CSRF tokens on web routes — and then you, or the person who joined the team last summer, opt out one convenient line at a time. This is a tour of the lines worth watching, with the actual APIs and the actual trade-offs.
Mass Assignment Is The First Thing An Attacker Tries
Eloquent's fill() and create() happily accept anything in the request payload unless you tell them otherwise. The defence is the model's $fillable allowlist:
// app/Models/User.php
protected $fillable = ['name', 'email', 'password'];
protected $hidden = ['password', 'remember_token'];
$guarded is the inverse — block this list, allow the rest. It works, but it's a denylist, and denylists rot. Adding a column to users six months later silently widens what the public POST endpoint can write. The pattern that holds up in production is $fillable on every model, plus a FormRequest that whitelists fields again at the HTTP edge.
The single most dangerous Laravel anti-pattern is protected $guarded = []; in a model that is fillable from request input. Search for it on day one of any audit:
grep -rn '\$guarded\s*=\s*\[\s*\]' app/Models
If you find it on a User or Order, treat it as a bug.
SQL Injection: Eloquent Is Safe, DB::raw Is Not
The query builder and Eloquent parameterise everything you pass through where(), whereIn(), bindings, and route-model binding. They are not where SQL injection happens. SQL injection happens here:
// Dangerous: user input concatenated into raw SQL
User::whereRaw("email = '{$request->email}'")->first();
// Safe: bindings keep the value out of the query string
User::whereRaw('email = ?', [$request->email])->first();
DB::raw(), whereRaw(), selectRaw(), orderByRaw() are escape hatches — useful for window functions and aggregates, deadly for user input. Either don't pass request values into them, or pass them as separate bindings.
XSS: Blade Escapes, {!! !!} Does Not
{{ $value }} runs through e(), which calls htmlspecialchars with ENT_QUOTES and double-encoding on. That is the right default for almost every variable on the page. The unescaped form {!! $value !!} is for HTML you trust — Markdown you converted yourself, sanitised rich-text content, or static markup. Never put user input into it without running it through a sanitiser like mews/purifier or HTMLPurifier first.
The follow-up rule: when you render anything inside an HTML attribute, the same {{ }} works. When you drop a value inside a <script> tag, use @js($payload) so it serialises as JSON and escapes the closing tag bytes correctly:
<script>
window.__bootstrap = @js($bootstrap);
</script>
CSRF Is Free On Web Routes, Not On API Routes
Laravel's web middleware group includes VerifyCsrfToken, so any POST/PUT/PATCH/DELETE through a web.php route already requires a token. @csrf in your form, <meta name="csrf-token"> for Axios, done.
Routes registered through routes/api.php use the api middleware group, which does not include CSRF — that's intentional, because token-based auth (Sanctum, Passport) doesn't need it. The trap is mixing the two: an admin endpoint that lives under /api/... but is called from the same browser session sometimes loses CSRF because someone moved it. Sanctum's SPA flow uses session cookies and reinstates CSRF via EnsureFrontendRequestsAreStateful. If you build an SPA on the same domain, set up the Sanctum stateful path correctly.
Authentication: Argon2id, Sanctum Tokens, Throttling
Modern Laravel hashes passwords with Bcrypt by default and supports Argon2 (Argon2id is the recommended variant) via config/hashing.php. Either is fine; do not roll your own. The actual mistake teams make is everywhere else around auth:
- Login throttling. Apply
throttle:login(the built-inRouteServiceProviderrate limiter) on/loginand/password/email. Without it, password spraying is aforloop. - Session fixation. Laravel regenerates the session ID on login by default — don't override that.
- Sanctum token storage. Personal access tokens are hashed at rest. The plaintext token is shown exactly once at creation. Treat the database column as if it were a password column.
- Email verification + password reset tokens. Both are hashed on disk and time-limited. Use the bundled
MustVerifyEmailandResetPasswordnotifications instead of inventing your own.
For API tokens, prefer Sanctum unless you actually need OAuth2 (Passport). Sanctum's expiration is opt-in: set expiration in config/sanctum.php to something like 60 * 24 * 7 (one week) so a leaked token has a horizon.
Security Headers Cost Nothing And Block A Lot
The browser will enforce policies the server tells it to enforce. The minimum useful set:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload— once you commit, you commit.Content-Security-Policy— at leastdefault-src 'self', with explicit allowlists for scripts and styles.frame-ancestors 'none'replacesX-Frame-Options: DENYcleanly.Referrer-Policy: strict-origin-when-cross-originX-Content-Type-Options: nosniffPermissions-Policyfor the APIs you don't use (camera, microphone, geolocation).
You can write a small middleware, or install bepsvpt/secure-headers and configure them in one file. Either approach beats remembering to add them to every response. CSP is the one that needs work — start in Content-Security-Policy-Report-Only mode, watch the browser console, then promote it to enforcing once your real assets are allowlisted.
File Uploads: MIME Types, Random Names, Off The Web Root
The default Request::file('avatar')->store('avatars') is fine until someone uploads evil.php.jpg and you serve it from public/. The hardening rules:
$path = $request->validate([
'avatar' => [
'required',
'file',
'mimetypes:image/jpeg,image/png,image/webp', // sniffs the file, not the extension
'max:4096',
'dimensions:max_width=4000,max_height=4000',
],
])['avatar']->store('avatars', 's3-private'); // private bucket, not the web root
Use mimetypes: (sniffed) over mimes: (extension). Store under a random filename — Storage::disk('s3')->putFile() does this automatically. Keep the bucket private and serve through a temporary signed URL or a controller that checks authorization. For high-risk uploads (anything users share with each other), run a malware scan; the enlightn/security-checker package can wire ClamAV in.
Rate Limiting Is Security, Not Performance
throttle:60,1 on a public endpoint is the difference between an inconvenience and an outage. Laravel's named limiters (RateLimiter::for('login', ...) in RouteServiceProvider) let you key on the user's IP, the email they typed, or both — useful so one attacker rotating IPs against one email still gets blocked.
The endpoints that always need limiters: login, password reset, email verification resend, signup, contact forms, anything that sends an email or SMS, anything that touches a paid third-party API. The default throttle:api is a reasonable starting point for read-heavy public APIs but is too generous for auth endpoints — write a stricter limiter for those.
Dependencies Are The Quiet Risk
Half of the public Laravel CVEs in the last five years lived in dependencies, not core. The tooling is now good enough that there's no excuse:
composer audit # Composer 2.4+, checks the FriendsOfPHP advisory DB
npm audit --omit=dev # frontend half
Run both in CI. Wire GitHub Dependabot or Snyk to file PRs against the lockfile. When a Laravel security release lands (the Laravel team publishes them on the security page), patch within the week — these are usually one-line composer update laravel/framework steps.
For static analysis, larastan (PHPStan with Laravel rules) at level 5 or higher catches a lot of the type-confusion bugs that turn into security bugs — null where a model was expected, request input treated as a string when it's an array, accidental comparison with ==. Psalm is the alternative. Either one running in CI is worth more than a written checklist.
Secrets, Logs, And The 2 AM Problem
The last category is the one that bites in the postmortem. A few rules that pay rent:
.envis never committed. Check.gitignore. Then check the actual log ofgit ls-files | grep -i envto be sure.- Production secrets live in your platform (Vercel, Forge, Vapor, AWS Parameter Store, Vault), not in a
.envfile someone scp'd onto the box. APP_DEBUG=falsein production. The debug page leaks env, stack traces, and bound parameters.- Logs do not contain passwords, tokens, full credit card numbers, or anything personally identifying you wouldn't want in a Slack channel. Laravel's
LogManagersupports aprocessorsarray — add one that scrubspassword,token,secret, andauthorizationheaders before the line is written. - An audit log for sensitive actions (role changes, refunds, data exports). It doesn't need to be fancy — an
auditstable withactor_id,action,subject_type,subject_id,meta,created_atis enough to answer "who did this and when" three months later when you need it.
A One-Sentence Mental Model
Laravel ships a strong default — keep it strong by allowlisting fields with $fillable, escaping output through {{ }}, sending requests through FormRequest + policies + rate limiters, hardening headers and uploads at the edge, and running composer audit plus a static analyser in CI before the next deploy.



