Laravel ships with strong security defaults — CSRF protection, escaped Blade output, hashed passwords, prepared statements. Most breaches in Laravel apps don’t come from a framework flaw; they come from a default that was quietly turned off, an $guarded = [] left in from scaffolding, or a raw query pasted in under deadline pressure. This is a practical walkthrough of the places that actually matter.

Keep Dependencies Updated

The simplest, highest-leverage security practice is also the most neglected. A large share of real-world breaches exploit a known vulnerability in an outdated package, not a novel attack.

composer outdated --direct
composer audit

composer audit checks your installed packages against a database of known CVEs and should run in CI on every build, not just when someone remembers to check.

Environment and Secrets

Your .env file holds database credentials, API keys, and the application key used to encrypt sessions and signed URLs. Treat it as a secret, not a config file.

APP_ENV=production
APP_DEBUG=false
APP_KEY=base64:generated-key-here

APP_DEBUG=true in production is one of the most common real-world mistakes — it renders full stack traces, file paths, and environment variables directly in error pages to anyone who triggers an exception. Confirm it’s false before every deploy, and add .env to .gitignore on day one, not after it’s already in your history.

Mass Assignment Protection

Eloquent’s mass assignment is convenient and dangerous in equal measure. Without a $fillable allowlist, a form submission can set fields it was never meant to touch:

class User extends Authenticatable
{
    protected $fillable = ['name', 'email', 'password'];

    // NOT fillable: is_admin, email_verified_at, balance
}
// If $fillable didn't restrict this, a crafted request
// with an extra `is_admin=1` field would silently work:
User::create($request->all());

Always validate with a Form Request and pass only the validated subset — $request->all() should never reach create() or update() directly:

public function store(StoreUserRequest $request): RedirectResponse
{
    User::create($request->validated());

    return redirect()->route('users.index');
}

SQL Injection and Query Builder Safety

Eloquent and the query builder parameterize bindings automatically — the vulnerability shows up when raw SQL is introduced and a value is concatenated instead of bound:

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

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

// Safer still — stay in the query builder
User::where('email', $email)->first();

The same rule applies to whereRaw(), orderByRaw(), and selectRaw() — any *Raw method accepts an optional bindings array as its second argument, and it should never be skipped in favor of string interpolation.

Cross-Site Scripting (XSS)

Blade escapes output by default. {{ $comment->body }} runs through htmlspecialchars() before it ever reaches the browser. The unescaped syntax exists for a reason, and that reason is not “the string looked fine in testing”:

{{-- Safe: entities are escaped --}}
<p>{{ $comment->body }}</p>

{{-- Dangerous: renders raw HTML, including <script> tags --}}
<p>{!! $comment->body !!}</p>

Reach for {!! !!} only for content you generated and trust yourself — a Markdown renderer’s output, for instance — never for anything a user typed into a form.

CSRF Protection

Every POST, PUT, PATCH, and DELETE request from a browser needs a CSRF token, or Laravel rejects it with a 419 response. This is enabled by default for any route inside the web middleware group — the failure mode people run into is usually a form missing the token, not the protection being absent:

<form method="POST" action="/posts">
    @csrf
    <input type="text" name="title">
</form>

Webhook endpoints (Stripe, GitHub, etc.) can’t send a CSRF token because they’re not browser form submissions — exclude only those specific routes, and never disable CSRF protection globally to fix one endpoint:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'webhooks/stripe',
    ]);
})

Authorization with Policies and Gates

Authentication answers “who are you?” — authorization answers “are you allowed to do this?” Skipping the second question is how one user ends up editing another user’s data by changing an ID in the URL.

php artisan make:policy PostPolicy --model=Post
class PostPolicy
{
    public function update(User $user, Post $post): bool
    {
        return $user->id === $post->user_id || $user->isAdmin();
    }
}

Enforce it in the controller — don’t rely on the frontend hiding a button as your only defense:

public function update(UpdatePostRequest $request, Post $post): RedirectResponse
{
    $this->authorize('update', $post);

    $post->update($request->validated());

    return redirect()->route('posts.show', $post);
}

$this->authorize() throws a 403 automatically if the policy returns false — there’s no path through this method that skips the check.

Rate Limiting Sensitive Endpoints

Login, password reset, and registration endpoints are the targets of automated credential-stuffing and brute-force attempts. Throttle them more aggressively than the rest of your API:

RateLimiter::for('login', function (Request $request) {
    return Limit::perMinute(5)->by($request->ip().'|'.$request->input('email'));
});
Route::post('/login', [AuthController::class, 'login'])
    ->middleware('throttle:login');

Keying the limiter by IP and email means an attacker can’t work around it by rotating email addresses from the same machine, or hammering one account from many IPs, without hitting a limit on one axis or the other.

Security Headers

A handful of response headers close off entire classes of attacks with no application logic required. Add them in middleware:

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

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

        return $response;
    }
}

X-Frame-Options: DENY stops your site from being embedded in an invisible <iframe> for clickjacking. X-Content-Type-Options: nosniff stops the browser from guessing content types in a way that can be exploited to execute an uploaded file as script.

Security is layered, not singular. CSRF protection doesn’t matter if authorization is missing; authorization doesn’t matter if SQL injection lets an attacker bypass the query entirely. Each layer assumes the others might fail — that’s the point.

Security Checklist Before Every Deploy

  • APP_DEBUG=false and APP_ENV=production confirmed in the live .env
  • Every mass-assignable model has an explicit $fillable, reviewed for admin-only or system fields
  • No *Raw() query method receives interpolated user input
  • {!! !!} is grepped for and each usage is justified in a code comment
  • Every mutating controller action calls $this->authorize() or an equivalent policy check
  • Auth-related routes (login, register, password reset) have dedicated, tighter rate limits
  • composer audit runs in CI and blocks merges on known CVEs
  • Security headers are set globally, not per-route

None of this is exotic. It’s a checklist of defaults that are easy to erode one shortcut at a time — the goal is to make sure the shortcuts never make it past code review.