Most Laravel applications we are asked to audit are not insecure because someone did something exotic. They are insecure because of a handful of boring, repeatable omissions: a $guarded = [] model behind a public form, a .env committed in 2021 and never rotated, a storage disk set to public that holds invoices, a dependency three majors behind, and no security headers at all.
This is the hardening pass we run on a Laravel 13 codebase before it goes to production, in the order we run it. Work through it with php artisan open in one terminal and your browser dev tools in the other.
1. Fix mass assignment properly
Model::unguard() and protected $guarded = [] are the single most common way a Laravel app leaks privilege. If a User model is unguarded and your registration controller does User::create($request->all()), anyone can POST is_admin=1.
Use explicit $fillable on every model that is ever filled from request data:
class User extends Authenticatable
{
protected $fillable = ['name', 'email', 'password'];
}
Better still, stop passing raw request data into models at all. Validate first, then fill from the validated subset:
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users'],
'password' => ['required', Password::defaults()],
]);
$user = User::create($data);
Then turn on strict models in a non-production service provider so the framework shouts at you when something is missing:
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::shouldBeStrict(! $this->app->isProduction());
}
Grep for the usual suspects before you move on:
grep -rn "guarded = \[\]" app/Models
grep -rn "unguard()" app/ bootstrap/
grep -rn "::create(\$request->all())\|->update(\$request->all())" app/
2. Close the authorization gaps, not just the authentication ones
Broken object-level access control is the defect we find most often, and it never shows up in a smoke test because the tester only ever opens their own records.
The cheap structural fix is to resolve tenant-scoped models through the route, not through a raw ID:
// Bad: trusts the ID in the URL.
$invoice = Invoice::findOrFail($request->integer('invoice'));
// Better: scoped binding, so /teams/{team}/invoices/{invoice}
// 404s when the invoice does not belong to the team.
Route::scopeBindings()->group(function () {
Route::get('/teams/{team}/invoices/{invoice}', [InvoiceController::class, 'show']);
});
Add a global scope for the current tenant, authorize in the controller ($this->authorize('view', $invoice)), and write the negative test — the one where user B asks for user A's record and must get a 403 or 404:
it('does not leak another team invoice', function () {
$mine = User::factory()->create();
$theirs = Invoice::factory()->create();
$this->actingAs($mine)->get("/invoices/{$theirs->id}")->assertNotFound();
});
One of these tests per resource is worth more than any scanner.
3. Send security headers
Laravel sends none of these for you. Add one middleware and append it to the web group in bootstrap/app.php:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class SecurityHeaders
{
public function handle(Request $request, Closure $next)
{
$response = $next($request);
$response->headers->add([
'X-Content-Type-Options' => 'nosniff',
'X-Frame-Options' => 'DENY',
'Referrer-Policy' => 'strict-origin-when-cross-origin',
'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()',
'Strict-Transport-Security' => 'max-age=31536000; includeSubDomains',
]);
return $response;
}
}
A Content-Security-Policy is the one with teeth, and the one that breaks pages if you bolt it on carelessly. Laravel's Vite integration can emit a nonce for you, which lets you avoid unsafe-inline:
// AppServiceProvider::boot()
Vite::useCspNonce();
// In the middleware, after Vite::useCspNonce() has run:
$nonce = Vite::cspNonce();
$response->headers->set('Content-Security-Policy',
"default-src 'self'; ".
"script-src 'self' 'nonce-{$nonce}'; ".
"style-src 'self' 'nonce-{$nonce}'; ".
"img-src 'self' data: https:; ".
"frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
);
Ship it in report-only mode first (Content-Security-Policy-Report-Only), collect violations for a week, then enforce. Also force HTTPS URLs in production so a mixed-content asset does not undo the work:
URL::forceScheme('https');
4. Treat secrets as rotatable, and keep them out of the repo
Rules we hold clients to:
.envis never committed..env.examplehas keys with empty values only.- Every secret that has ever appeared in git history is considered burned. Rotate it, do not just delete the file.
- Production secrets live in the platform's secret store (Laravel Cloud environment variables, Forge environment, AWS Secrets Manager), not in a shared password doc.
APP_DEBUG=falseandAPP_ENV=productionin production. A debug-mode exception page prints environment variables to the browser; it is the fastest full compromise there is.- Config is cached (
php artisan config:cache) soenv()is never called at runtime outside config files.
Add a CI check so a secret cannot sneak back:
composer require --dev enlightn/security-checker
php artisan security-check:now
and a history scan with gitleaks detect --no-banner in the same pipeline.
5. Lock down file uploads and storage
Two separate problems: what you accept, and where it lands.
$request->validate([
'avatar' => ['required', 'file', 'mimes:jpg,jpeg,png,webp', 'max:5120', 'dimensions:max_width=4000,max_height=4000'],
'contract' => ['required', 'file', 'mimetypes:application/pdf', 'max:20480'],
]);
Use mimetypes (checks the actual content type) rather than only mimes (extension) for anything sensitive, and never trust getClientOriginalName() as a path. Store with a generated name:
$path = $request->file('contract')->store('contracts', 's3');
Private documents belong on a private disk, served through a controller that authorizes, or via a short-lived signed URL:
Gate::authorize('view', $contract);
return Storage::disk('s3')->temporaryUrl($contract->path, now()->addMinutes(5));
If you have user-uploaded files on the public disk today, audit that directory before you do anything else. Public object storage is how invoices end up in search engines.
6. Rate limit the endpoints that matter
Throttling is not only about load. Login, password reset, OTP verification, invite acceptance, and any endpoint that costs you money (email, SMS, AI generation) need named limiters:
RateLimiter::for('login', fn (Request $request) => [
Limit::perMinute(5)->by($request->input('email').'|'.$request->ip()),
Limit::perMinute(20)->by($request->ip()),
]);
Key by identity and IP, as above, so one attacker cannot lock out a real user by hammering their email address. Laravel's built-in AuthenticateSession plus auth.session middleware, session regeneration on login, and logging out other devices on password change close the rest of the session gaps.
7. Escape output deliberately
Blade escapes by default; the holes are the places where you opted out.
grep -rn "{!!" resources/views
Every hit needs a reason. If you render user-supplied HTML (a rich-text field, an email signature), sanitise it server-side on save with an allow-list library such as ezyang/htmlpurifier — not with a regex, and not in JavaScript. Two more that catch people out:
- Passing user data into a
<script>block: use@js($value), not string interpolation. - Rendering a user-controlled URL in
href: validate the scheme, orjavascript:links become stored XSS.
8. Keep dependencies current, automatically
An unpatched dependency is the most likely way you get breached, and the fix is scheduling rather than cleverness.
composer audit
npm audit --omit=dev
composer outdated --direct
Run composer audit in CI and fail the build on known advisories. Turn on Dependabot or Renovate for weekly PRs, keep PHP itself on a supported version, and stay on a supported Laravel release — our Laravel version upgrade service exists precisely because applications that skip this for four years cannot be patched in an afternoon.
9. Log, alert, and be able to answer "what happened?"
Security work you cannot observe is security theatre. Log authentication events, authorization failures, password and email changes, role changes, impersonation, and admin exports — with actor, target, IP, and timestamp, into a store the application itself cannot rewrite. Keep PII out of exception payloads, scrub tokens from logs, and set LOG_LEVEL so production does not record request bodies.
A checklist you can run before each release
-
APP_DEBUG=false,APP_ENV=production, config and routes cached - No
$guarded = []on request-filled models;shouldBeStrict()on in dev and CI - Policies on every resource, plus one negative access test per resource
- Security headers middleware applied; CSP enforced
- HTTPS forced, HSTS set, secure and http-only session cookies,
SESSION_SECURE_COOKIE=true - Private files on a private disk behind authorization or signed URLs
- Named rate limiters on login, reset, OTP and paid endpoints
-
{!! !!}audited; user HTML sanitised on save -
composer auditandnpm auditgreen in CI; automated dependency PRs enabled - Audit log of security-relevant events, with alerting on spikes of 403s and failed logins
- Backups tested by actually restoring one
None of this is hard individually. The value is in doing all of it, writing the tests that keep it done, and re-running the pass after every major feature.
If you would like a second pair of eyes, we run fixed-scope Laravel security reviews and implement the remediation — see Laravel Security Implementation or get in touch.