How do I secure a Laravel app?

Direct answer

Set `APP_DEBUG=false` in production, define `$fillable` on every model, and scope route model binding to the current user. Laravel's defaults handle CSRF, escaping and query parameterisation. The recurring problems are debug mode left on, mass assignment, and authorisation checks that confirm login without confirming ownership.

Muhammad HasanUpdated

Debug mode

APP_DEBUG=false
APP_ENV=production

Laravel's debug error page, Ignition. Displays the stack trace, the code around the error, and environment variables including database credentials and API keys. To anyone who triggers an error.

This is the single most damaging misconfiguration in a Laravel app and it's common, because it's true by default in a fresh install and easy to leave that way.

Mass assignment

// Dangerous if $fillable isn't set
User::create($request->all());

Without $fillable or $guarded, that assigns every submitted field, including ones your form never showed. Sending is_admin=1 gets assigned.

protected $fillable = ['name', 'email'];

Prefer $fillable over $guarded, an allowlist beats a blocklist, and a new column added later is safe by default rather than exposed by default.

Better still, validate first and assign the validated set:

$data = $request->validate([
    'name'  => 'required|string|max:255',
    'email' => 'required|email',
]);
User::create($data);

Route model binding needs scoping

// Vulnerable — any logged-in user gets any order
Route::get('/orders/{order}', function (Order $order) {
    return $order;
})->middleware('auth');

The auth middleware confirms someone is logged in. Binding then resolves whatever ID was in the URL.

Route::get('/orders/{order}', function (Order $order) {
    Gate::authorize('view', $order);
    return $order;
})->middleware('auth');

Policies are the durable version of this. Generate one per model, register it, and call authorize in the controller. That way the check lives with the model rather than being remembered per route.

Laravel also supports scoped bindings, resolving the child through the parent relationship, which prevents the mismatch by construction.

Queries

Eloquent and the query builder parameterise. DB::raw and whereRaw don't:

// Vulnerable
DB::select("SELECT * FROM users WHERE email = '$email'");

// Safe
DB::select("SELECT * FROM users WHERE email = ?", [$email]);

Blade

{{ $value }} escapes. {!! $value !!} doesn't. Any unescaped directive applied to user content is potential XSS.

The rest

CSRF middleware is on by default, check what's been added to the $except array in VerifyCsrfToken, since exemptions accumulate.

Storage: uploads go to a disk outside the public directory, served through a controller that checks authorisation, not by symlinking everything into public.

php artisan config:cache in production, and confirm .env isn't web-accessible.

Tooling

Larastan and Enlightn both cover Laravel-specific issues, and composer audit covers known CVEs in packages. As elsewhere, none of them reliably catch a missing ownership check, because the vulnerable code is indistinguishable from correct code without knowing the intent.

Go deeper

Kolega for AppSec teams

Related answers

See what your own repository returns

Connect a repo and run a scan. No credit card, no pipeline changes.

Get started for free