SQL injection, mass assignment, XSS, CSRF, insecure direct object references, exposed .env files, missing rate limiting, API key leakage — a brutally honest security audit of a typical Laravel app with the exact fixes for each vulnerability.
Most Laravel applications are not insecure by design. They’re insecure because of small, specific decisions made under time pressure — decisions that felt fine in the moment and create real exposure in production.
The good news: Laravel ships with strong security defaults. The bad news: every one of those defaults can be accidentally bypassed, and the bypasses are remarkably common.
This is a security audit of a typical Laravel application — not a theoretical exercise, but the specific vulnerabilities that appear most often in production apps. For each one: what the vulnerability is, what it looks like in real code, and the exact fix.
Item 1: SQL Injection via Raw Queries
Status in most apps: Mostly safe, with dangerous exceptions
Laravel’s query builder and Eloquent use PDO prepared statements by default. The vulnerability appears when developers reach for raw expressions:
// ✗ Vulnerable — user input directly in raw SQL
$users = DB::select("SELECT * FROM users WHERE name = '{$request->name}'");
// ✗ Also vulnerable — raw where clause
$users = User::whereRaw("name = '{$request->name}'")->get();
// ✗ Vulnerable orderBy with unsanitised column name
$column = $request->input('sort_by');
$users = User::orderByRaw("{$column} DESC")->get();
The last one is subtle and extremely common. An attacker passes name = '' OR 1=1 -- as the sort column and gets all rows.
// ✓ Safe — parameterised binding
$users = DB::select("SELECT * FROM users WHERE name = ?", [$request->name]);
// ✓ Safe — query builder binding
$users = User::whereRaw("name = ?", [$request->name])->get();
// ✓ Safe — whitelist approach for dynamic columns
$allowedColumns = ['name', 'email', 'created_at'];
$column = in_array($request->sort_by, $allowedColumns)
? $request->sort_by
: 'created_at';
$users = User::orderBy($column, 'desc')->get();
Audit: Search your codebase for DB::select(, whereRaw(, orderByRaw(, havingRaw(, selectRaw(. For every one, verify that no $request variable is directly interpolated — only bound via ? or named bindings.
Item 2: Mass Assignment Vulnerabilities
Status in most apps: Common vulnerability
Mass assignment lets attackers inject fields the application didn’t intend to set:
// ✗ The dangerous pattern — request data directly to create/update
public function store(Request $request): JsonResponse
{
$user = User::create($request->all());
return response()->json($user);
}
An attacker adds "is_admin": true or "role": "admin" to the request body. Laravel will set those fields if the model allows it.
// ✗ Using $guarded = [] — everything is fillable
class User extends Model
{
protected $guarded = []; // no protection whatsoever
}
// ✓ Explicit $fillable — only these fields can be mass-assigned
class User extends Model
{
protected $fillable = [
'name',
'email',
'password',
// 'is_admin' — intentionally NOT here
// 'role' — intentionally NOT here
];
}
// ✓ Validate before creating — only validated fields can be assigned
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users'],
'password' => ['required', 'confirmed', 'min:8'],
]);
$user = User::create($validated);
return response()->json(new UserResource($user));
}
Audit: Find every model. Verify $fillable is explicitly defined or $guarded lists sensitive fields. Search for ->create($request->all()) and ->update($request->all()) — both are potential mass assignment vulnerabilities.
Item 3: XSS — Cross-Site Scripting
Status in most apps: Mostly safe in Blade, vulnerable in Vue/React
Blade’s {{ }} syntax escapes output by default. The vulnerability appears with {!! !!}:
{{-- ✗ Unescaped — XSS vulnerability --}}
{!! $user->bio !!}
{!! $comment->content !!}
{{-- ✓ Escaped — safe --}}
{{ $user->bio }}
{{ $comment->content }}
In Vue and React templates:
<!-- ✗ Vulnerable — renders raw HTML from user input -->
<div v-html="userContent"></div>
<!-- ✓ Safe — text binding, no HTML parsing -->
<div>{{ userContent }}</div>
When you genuinely need to render user-provided HTML (rich text editors, Markdown output), sanitise first:
// composer require ezyang/htmlpurifier
use HTMLPurifier;
use HTMLPurifier_Config;
class ContentSanitizer
{
public function sanitize(string $html): string
{
$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,br,b,i,strong,em,ul,ol,li,a[href],h2,h3,h4,blockquote,code,pre');
$config->set('HTML.TargetBlank', true);
$config->set('URI.DisableExternalResources', false);
$purifier = new HTMLPurifier($config);
return $purifier->purify($html);
}
}
// In a model accessor or when storing
$model->content = app(ContentSanitizer::class)->sanitize($request->content);
Audit: Search for {!! !!} in Blade files. Every instance should be either sanitised server-side or rendering trusted content only. Search for v-html in Vue files.
Item 4: Insecure Direct Object References (IDOR)
Status in most apps: Very commonly vulnerable — this is item 4
IDOR is when a user can access another user’s data by changing an ID in the URL:
// ✗ No authorisation — any authenticated user can view any order
public function show(int $id): JsonResponse
{
$order = Order::findOrFail($id);
return response()->json($order);
}
// An attacker changes /api/orders/42 to /api/orders/43
// They now see another customer's order
// ✓ Scope to the authenticated user
public function show(int $id): JsonResponse
{
$order = Order::where('user_id', auth()->id())
->findOrFail($id);
return response()->json($order);
}
// Or better — use Route Model Binding with a policy
public function show(Order $order): JsonResponse
{
$this->authorize('view', $order);
return response()->json($order);
}
// app/Policies/OrderPolicy.php
class OrderPolicy
{
public function view(User $user, Order $order): bool
{
return $user->id === $order->user_id
|| $user->hasRole('admin');
}
}
The alternative: use UUIDs instead of sequential IDs for public-facing resources. Sequential IDs reveal record counts and make enumeration trivial. UUIDs make guessing impossible:
// Migration — UUID primary key
$table->uuid('id')->primary();
// Model
use Illuminate\Database\Eloquent\Concerns\HasUuids;
class Order extends Model
{
use HasUuids;
}
Audit: Review every controller method that accepts an ID parameter. For each one: is the record scoped to the authenticated user? Is there an authorization check? Can an attacker access /api/orders/1 through /api/orders/1000 and retrieve other users’ data?
Item 5: CSRF — Cross-Site Request Forgery
Status in most apps: Safe for web routes, often missing for API routes
Laravel’s VerifyCsrfToken middleware protects all POST, PUT, PATCH, DELETE routes in the web middleware group. The vulnerability appears when developers incorrectly add routes to the $except list or build APIs without proper token verification:
// ✗ Excluding a route that modifies data — CSRF vulnerability
class VerifyCsrfToken extends Middleware
{
protected $except = [
'webhooks/*', // fine for webhooks
'api/upload', // ✗ WRONG — this should not be excluded
];
}
// ✓ Webhooks should be excluded (no browser-initiated request)
// ✓ API routes using Sanctum or token auth are safe without CSRF
// (CSRF tokens are for browser-based session auth)
protected $except = [
'stripe/webhook',
'github/webhook',
];
For SPAs using Laravel Sanctum:
// Before any state-changing request, fetch the CSRF cookie
await axios.get('/sanctum/csrf-cookie')
// Now include the X-XSRF-TOKEN header (axios does this automatically)
await axios.post('/api/orders', orderData)
Audit: Review $except in VerifyCsrfToken. Every excluded route should be a webhook or an API route using token authentication — never a route that performs actions on behalf of a logged-in browser user.
Item 6: Exposed Sensitive Files
Status in most apps: Often misconfigured
A misconfigured server serving the .env file is one of the most catastrophic exposures possible. Your database credentials, API keys, and application key are all in that file.
# Test your own server
curl https://yourapp.com/.env
# If you get anything other than a 404, you have a critical vulnerability
The correct Nginx configuration prevents this:
# Block access to hidden files and directories
location ~ /\. {
deny all;
return 404;
}
# Block access to specific sensitive files
location ~ \.(env|log|md|gitignore|gitattributes)$ {
deny all;
return 404;
}
Other files that should never be web-accessible:
# Verify these return 404 from the browser
/.git/config
/.git/HEAD
/storage/logs/laravel.log
/vendor/autoload.php
/.env.backup
/.env.local
In production, Laravel should always run with the document root set to /public — not the application root. If your server points to the application root, all of the above are accessible.
# Correct — document root is the public directory
root /var/www/yourapp/public;
# Wrong — never do this
root /var/www/yourapp;
Item 7: Missing or Insufficient Rate Limiting
Status in most apps: Almost always missing — this is item 7
Login endpoints without rate limiting allow brute-force attacks. Password reset endpoints without rate limiting allow user enumeration. API endpoints without rate limiting allow scraping and abuse.
// ✗ No rate limiting on authentication
Route::post('/login', [AuthController::class, 'login']);
Route::post('/forgot-password', [PasswordController::class, 'send']);
// ✓ Named rate limiters per endpoint type
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
// In RouteServiceProvider or AppServiceProvider
RateLimiter::for('login', function (Request $request) {
return [
// Max 5 attempts per IP per minute
Limit::perMinute(5)->by($request->ip()),
// Max 3 attempts per email+IP per minute
Limit::perMinute(3)->by($request->input('email') . '|' . $request->ip()),
];
});
RateLimiter::for('api', function (Request $request) {
return $request->user()
? Limit::perMinute(120)->by($request->user()->id)
: Limit::perMinute(20)->by($request->ip());
});
// Apply in routes
Route::middleware(['throttle:login'])->group(function () {
Route::post('/login', [AuthController::class, 'login']);
Route::post('/forgot-password', [PasswordController::class, 'send']);
Route::post('/register', [AuthController::class, 'register']);
});
Password Reset Timing Attack: Returning different responses for “email exists” vs “email not found” allows user enumeration. Always return the same response regardless:
// ✓ Same response whether email exists or not
public function send(Request $request): JsonResponse
{
$request->validate(['email' => 'required|email']);
// Always returns 'passwords.sent' — doesn't reveal if email exists
Password::sendResetLink($request->only('email'));
return response()->json(['message' => 'If that email exists, we sent a reset link.']);
}
Item 8: Insecure File Uploads
Status in most apps: Commonly misconfigured
// ✗ Storing uploads in the public directory — executable
$path = $request->file('avatar')->store('avatars', 'public');
// File is now accessible at /storage/avatars/filename.jpg
// If someone uploads a .php file and it gets executed — RCE
// ✓ Validate file type with both MIME type and extension
$request->validate([
'avatar' => [
'required',
'file',
'mimes:jpg,jpeg,png,gif,webp', // extension whitelist
'mimetypes:image/jpeg,image/png,image/gif,image/webp', // MIME type whitelist
'max:2048', // 2MB max
],
]);
// ✓ Store outside the web root — not publicly accessible by default
$path = $request->file('document')->store('documents');
// Stored in storage/app/documents — not web accessible
// Serve via a controller that checks authorization
// Secure file serving controller
public function download(string $filename): \Symfony\Component\HttpFoundation\StreamedResponse
{
$document = Document::where('filename', $filename)
->where('user_id', auth()->id())
->firstOrFail();
// Authorization check before serving
abort_unless(auth()->user()->can('download', $document), 403);
return Storage::download($document->path);
}
Generate random filenames — never use the original:
// ✗ Using original filename — path traversal potential
$path = $file->storeAs('uploads', $file->getClientOriginalName());
// ✓ Random filename with extension only
$extension = $file->getClientOriginalExtension();
$filename = \Str::uuid() . '.' . $extension;
$path = $file->storeAs('uploads', $filename);
Item 9: Sensitive Data in API Responses
Status in most apps: Frequently over-exposed
// ✗ Returning the full Eloquent model — exposes everything
public function show(User $user): JsonResponse
{
return response()->json($user);
// Returns: id, name, email, password hash, remember_token,
// two_factor_secret, stripe_id, ... everything
}
// ✓ API Resources — explicit, intentional field selection
class UserResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'created_at' => $this->created_at->toISOString(),
// password, remember_token, two_factor_secret — never here
];
}
}
// Always hide sensitive fields at the model level as a last defence
class User extends Model
{
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
'stripe_id',
'card_brand',
'card_last_four',
];
}
Item 10: Insecure Cookie Configuration
Status in most apps: Often missing Secure and SameSite flags
// config/session.php — production settings
return [
'secure' => env('SESSION_SECURE_COOKIE', true), // HTTPS only
'http_only' => true, // not accessible by JavaScript — prevents XSS theft
'same_site' => 'lax', // CSRF protection (use 'strict' for maximum protection)
'domain' => env('SESSION_DOMAIN', null),
'lifetime' => 120, // 2 hours
];
// For manually set cookies
Cookie::make(
name: 'user_preference',
value: $value,
minutes: 1440,
path: '/',
domain: null,
secure: true, // HTTPS only
httpOnly: true, // no JavaScript access
sameSite: 'lax',
);
Item 11: API Key and Secret Leakage
Status in most apps: A persistent problem — this is item 11
API keys in code, in logs, in error messages, in version control. This is the security failure with the widest blast radius.
Never Log Sensitive Data
// ✗ Logging the request — may contain API keys, tokens, passwords
Log::info('Incoming request', $request->all());
// ✗ Logging Stripe webhooks — contains customer data
Log::info('Stripe webhook', $payload);
// ✓ Log only what you need, sanitise sensitive fields
Log::info('Incoming request', [
'path' => $request->path(),
'method' => $request->method(),
'user_id' => auth()->id(),
// No payload — don't log request bodies
]);
Never Hardcode Secrets
// ✗ Hardcoded API key in source code
$client = new StripeClient('sk_live_abc123xyz');
// ✓ Always from environment
$client = new StripeClient(config('services.stripe.secret'));
// Which reads from: STRIPE_SECRET in .env
Rotate Keys After Git Exposure
If a secret was ever committed to git — even if immediately reverted — assume it is compromised. Git history is public and indexed by tools that scan for secrets:
# Check if secrets are in your git history
git log -p | grep -E '(sk_live|AKIA|AIza|ghp_)'
# If found: rotate the key immediately, then clean git history
git filter-branch --force --index-filter \
'git rm --cached --ignore-unmatch config/secrets.php' \
HEAD
# Or use git-filter-repo (modern alternative)
pip install git-filter-repo
git filter-repo --path config/secrets.php --invert-paths
Use Secret Scanning in CI
# .github/workflows/security.yml
name: Secret Scanning
on: [push, pull_request]
jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Gitleaks scans every commit for hardcoded secrets — API keys, passwords, connection strings — and fails the CI pipeline if any are found.
Item 12: Missing Security Headers
Status in most apps: Almost universally missing
// app/Http/Middleware/SecurityHeaders.php
class SecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
if (!app()->isLocal()) {
$response->headers->set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
}
// Start with report-only, verify, then switch to enforced CSP
$response->headers->set(
'Content-Security-Policy-Report-Only',
"default-src 'self'; script-src 'self'; object-src 'none';"
);
return $response;
}
}
Check your headers at securityheaders.com — it gives a letter grade and specific recommendations.
Item 13: Verbose Error Messages in Production
Status in most apps: Commonly misconfigured
# .env.production — these MUST be set correctly
APP_ENV=production
APP_DEBUG=false # if true, stack traces with .env values are shown to users
// config/app.php — verify this is enforced
'debug' => (bool) env('APP_DEBUG', false),
With APP_DEBUG=true in production, a failed request shows a full stack trace including:
- Database credentials from the config
- Full request data including POST bodies
- Application directory structure
- PHP version and loaded extensions
Audit: Request a route that throws an exception. If you see a stack trace in the browser in production, APP_DEBUG=true is set.
The Quick Security Audit Commands
Run these against your application before every major release:
# Check for common security issues in PHP code
composer require --dev enlightn/enlightn
php artisan enlightn
# Run the built-in security audit
composer audit
# Check for known vulnerable dependencies
composer require --dev roave/security-advisories
# Test your HTTP security headers
curl -I https://yourapp.com | grep -E '(X-Content-Type|X-Frame|Strict-Transport|Content-Security)'
# Verify .env is not accessible
curl -s -o /dev/null -w "%{http_code}" https://yourapp.com/.env
# Should return: 404
The Complete Security Checklist
SQL Injection:
✓ No user input directly interpolated in raw SQL strings
✓ Dynamic column names validated against a whitelist
✓ All query builder raw methods use ? bindings
Mass Assignment:
✓ Every model has explicit $fillable defined
✓ No ->create($request->all()) without prior validation
✓ Sensitive fields (is_admin, role) not in $fillable
XSS:
✓ All {!! !!} output is sanitised or trusted-only
✓ No v-html with user content in Vue/React
✓ Rich text content sanitised with HTMLPurifier
IDOR:
✓ Every endpoint that accepts an ID has authorization
✓ Policies defined for all major models
✓ Public-facing resources use UUIDs
CSRF:
✓ No data-modifying routes in $except list
✓ SPAs fetch CSRF cookie before state-changing requests
Sensitive Files:
✓ Document root points to /public not application root
✓ .env returns 404 from the browser
✓ .git directory not web accessible
Rate Limiting:
✓ Login, register, forgot-password have throttle middleware
✓ API routes have per-user and per-IP limits
✓ Password reset returns same response regardless of email existence
File Uploads:
✓ File type validated by both MIME type and extension
✓ Files stored outside web root
✓ Original filenames never used — UUID-based names only
API Responses:
✓ API Resources used for all responses — no raw model serialisation
✓ Sensitive fields in $hidden on all models
Cookies:
✓ SESSION_SECURE_COOKIE=true in production
✓ http_only=true for session cookie
✓ same_site=lax minimum
API Keys:
✓ No secrets hardcoded in source files
✓ No secrets in git history
✓ Gitleaks or equivalent running in CI
✓ Error logs sanitised — no request bodies logged
Security Headers:
✓ X-Content-Type-Options, X-Frame-Options set
✓ HSTS header on HTTPS
✓ CSP deployed (start with report-only)
Debug Mode:
✓ APP_DEBUG=false in production
✓ APP_ENV=production in production
Final Thoughts
Laravel’s security defaults are good. The problem is the specific, predictable ways those defaults get bypassed — {!! !!} instead of {{ }}, $request->all() instead of validated data, no policy on a route that accepts an ID, no rate limit on the login endpoint.
None of the vulnerabilities in this post require sophisticated attacks. IDOR requires changing a number in a URL. Mass assignment requires adding a field to a JSON body. XSS requires passing <script>alert(1)</script> as a name field. The attacks are trivial. The fixes are straightforward. The gap is awareness.
Run Enlightn. Fix what it flags. Add the security middleware. Set the cookie flags. Add rate limiting to auth endpoints. Check that .env returns 404.
An hour of this checklist is worth more than any security library you could install.
