Laravel provides secure building blocks for authentication, authorisation, CSRF protection, database access and file storage. It does not make an application secure automatically. Most critical vulnerabilities appear where developers model a business trust boundary incorrectly, take a convenient shortcut or test only the successful path.
The framework protects many technical defaults. Deciding who may access which data and actions remains the application's responsibility.
The following ten mistakes are especially relevant. They are not limited to old Laravel versions. A current application can still be vulnerable when its security assumptions are neither explicit nor tested negatively.
Confusing authentication with authorisation
An authenticated user is not automatically allowed to access every object. A typical unsafe implementation loads an invoice, ticket or project only by the ID in the URL. Changing 42 to 43 may expose another customer's record. Plain Route Model Binding resolves the object but does not authorise access. Scoped bindings can additionally ensure that a nested child belongs to the stated parent. Roles, actions and business state still require authorisation.
Policies and gates should verify the business action. Nested resources must also belong to the expected parent. A useful test confirms both that the owner receives access and that a second authenticated user receives 403 or 404.
Check every read and write action, including downloads, exports and APIs. Policies should account for role, tenant, object and business state.
The vulnerable version verifies only that a user is logged in:
public function show(Invoice $invoice): JsonResponse
{
return response()->json($invoice);
}
The safer version makes the business decision explicit and keeps it reusable across controllers, API resources and downloads:
final class InvoicePolicy
{
public function view(User $user, Invoice $invoice): bool
{
return $user->tenant_id === $invoice->tenant_id
&& ($user->id === $invoice->user_id || $user->can('invoices.view-all'));
}
}
public function show(Invoice $invoice): JsonResponse
{
Gate::authorize('view', $invoice);
return response()->json(InvoiceResource::make($invoice));
}
The negative test is just as important as the successful case:
it('does not expose another customers invoice', function () {
$tenant = Tenant::factory()->create();
$foreignTenant = Tenant::factory()->create();
$alice = User::factory()->for($tenant)->create();
$invoice = Invoice::factory()->for($foreignTenant)->create();
$this->actingAs($alice)
->getJson(route('invoices.show', $invoice))
->assertForbidden();
});
Recommendation: Keep the authorisation decision in a policy and write a cross-user or cross-tenant test for every sensitive object route. Include exports and file downloads in that inventory.
Applying tenant isolation only in controllers
A where('tenant_id', ...) condition in selected controllers is not sufficient for a multi-tenant platform. Data also leaves through jobs, notifications, search indexes, exports, cache entries, broadcast channels and administrative tools.
Treat tenant isolation as a system-wide invariant. The data model, queries, policies, queue payloads, cache keys and file paths must enforce the same boundary. Global scopes can help but do not replace understandable policies and tests.
Negative integration tests should create at least two tenants and test every relevant access path across them, especially background jobs and exports where no browser request provides current user context.
A job should carry the tenant identity explicitly instead of depending on request-scoped state:
final class ExportInvoices implements ShouldQueue
{
public function __construct(
public readonly int $tenantId,
public readonly int $requestedBy,
) {}
public function handle(): void
{
$tenant = Tenant::findOrFail($this->tenantId);
$user = User::whereBelongsTo($tenant)->findOrFail($this->requestedBy);
Gate::forUser($user)->authorize('export', [Invoice::class, $tenant]);
Invoice::query()
->whereBelongsTo($tenant)
->chunkById(500, fn ($invoices) => $this->append($invoices));
}
}
Recommendation: Define one tenant context strategy and use it in HTTP requests, console commands, queues, cache keys, file paths and search indexes. Test the invariant outside controllers too.
Passing unfiltered request data to Eloquent
Mass assignment is broader than $fillable. Passing $request->all() or an overly broad validated array to create(), update() or fill() may let a client control role, tenant_id, is_admin, status, price or approved_at when the model's $fillable or $guarded configuration permits those fields.
Use an explicit allowlist for each use case through a Form Request or DTO. Set permission-sensitive and server-derived fields separately. A regression test should submit additional privileged fields and confirm that they are rejected or ignored.
Do not make the persistence layer guess which request fields are trustworthy:
// Unsafe: every submitted key reaches the model.
$user->update($request->all());
// Better: $request is a Form Request with matching rules().
$data = $request->safe()->only(['name', 'timezone', 'locale']);
$user->update($data);
// Server-controlled values stay server-controlled.
$user->tenant_id = $request->user()->tenant_id;
$user->save();
Add a regression test that deliberately submits a forbidden field:
it('ignores privilege fields in profile updates', function () {
$user = User::factory()->create(['is_admin' => false]);
$this->actingAs($user)->patch(route('profile.update'), [
'name' => 'Updated name',
'is_admin' => true,
])->assertRedirect();
expect($user->fresh()->is_admin)->toBeFalse();
});
Recommendation: Use a separate Form Request or command DTO per write operation. Avoid generic methods such as updateProfile(array $data) when the accepted fields differ by actor or workflow.
Treating dynamic SQL structure like bound values
Eloquent and the query builder bind values safely through their intended APIs. Risk appears in whereRaw(), orderByRaw(), assembled search expressions and dynamic column names. Bindings protect values, not user-controlled SQL identifiers or fragments.
Sorting, filter operators and selectable columns need strict server-side mappings. Unknown values are rejected. During review, ask whether any part of SQL structure can be influenced by the request, not merely whether bindings are present.
// Unsafe: a binding cannot protect an SQL identifier.
$orders = Order::query()
->orderByRaw($request->string('sort').' '.$request->string('direction'))
->get();
// Better: public input maps to known internal expressions.
$sorts = [
'newest' => ['created_at', 'desc'],
'amount_asc' => ['total_cents', 'asc'],
'amount_desc' => ['total_cents', 'desc'],
];
[$column, $direction] = $sorts[$request->string('sort')->toString()] ?? $sorts['newest'];
$orders = Order::query()->orderBy($column, $direction)->get();
Recommendation: Map external filter and sorting vocabulary to fixed columns and operators. Reserve raw expressions for reviewed, static SQL with bound values.
Trusting Blade output and stored content incorrectly
Blade escapes {{ $value }} by default. {!! $value !!} removes that boundary. Unchecked profiles, CMS content, Markdown, imported HTML or error messages can then become stored XSS.
If HTML is required, sanitise it server-side against a narrow allowlist. Validate URLs separately because an escaped but dangerous scheme can still be harmful. Content Security Policy reduces impact but does not replace context-aware output handling.
Tests should cover text, attributes, links and structured content because safe handling depends on output context.
{{-- Safe for ordinary text because Blade escapes HTML. --}}
<h2>{{ $article->title }}</h2>
{{-- Unsafe when body_html contains untrusted or insufficiently sanitised HTML. --}}
<div>{!! $article->body_html !!}</div>
If rich text is a product requirement, sanitise it when writing and again when the sanitiser policy changes. The policy should allow only required elements and attributes. Links need an explicit protocol allowlist such as https, http and optionally mailto; CSS, event handlers, javascript: URLs and uncontrolled embeds remain forbidden.
Recommendation: Document which fields contain plain text, Markdown or trusted HTML. Render each through one central component and add payload tests for script tags, event attributes, malformed links and SVG.
Treating uploads as ordinary form fields
An extension and browser-supplied content type are not dependable security decisions. Attackers control filename, content and metadata. SVG and HTML may contain active content, images can attack parsers, and original public filenames can disclose data or overwrite files.
Uploads need size limits, server-side type checks, generated names and a deliberate storage class. Laravel's mimes and mimetypes rules derive a detected MIME type from file content; mimes:pdf does not merely trust the user-assigned extension. That is a type check, not proof that a complex document is harmless. Confidential documents belong on private storage and should be served only after authorisation through a controlled route or short-lived URL. Image re-encoding, malware scanning and audit logging may be appropriate.
$data = $request->validate([
'document' => ['required', 'file', 'mimes:pdf', 'max:10240'],
]);
$path = $data['document']->store(
"tenants/{$request->user()->tenant_id}/documents",
'local',
);
Document::create([
'tenant_id' => $request->user()->tenant_id,
'storage_path' => $path,
// A product-specific, server-controlled display name.
'download_name' => 'document.pdf',
]);
Authorisation still applies when a stored file is downloaded. In a fresh Laravel 13 application, the local disk points at storage/app/private. A disk named private exists only when the project configures it:
public function download(Document $document): StreamedResponse
{
Gate::authorize('view', $document);
return Storage::disk('local')->download(
$document->storage_path,
$document->download_name,
);
}
Recommendation: Keep confidential files outside the public web root. For higher-risk formats, isolate parsing and conversion, scan before release and prevent the application host from serving uploaded active content under the primary domain.
Fetching user-provided URLs without SSRF controls
Preview generators, webhooks, PDF services, import features and AI agents often fetch a URL supplied by a user. Without protection, the server may reach internal services, cloud metadata or local administration endpoints.
Checking only for https:// is insufficient. Use target allowlists, DNS and IP checks, blocked private and reserved ranges, restricted redirects, validation after every redirect, short timeouts, response-size limits and controlled network egress. Include DNS rebinding in the threat model.
// Unsafe: the application server can be used as a network proxy.
$response = Http::get($request->string('url')->toString());
// Prefer server-controlled destinations for known integrations.
$clients = [
'crm' => fn () => Http::baseUrl(config('services.crm.url')),
'billing' => fn () => Http::baseUrl(config('services.billing.url')),
];
$client = $clients[$request->string('integration')->toString()] ?? null;
abort_unless($client, 422, 'Unknown integration.');
$response = $client()
->connectTimeout(2)
->timeout(5)
->retry(2, 100)
->withoutRedirecting()
->get('/v1/status');
withoutRedirecting() prevents an allowed host from redirecting this request to an internal destination. Laravel's HTTP client does not automatically validate redirect destinations as an SSRF control. When arbitrary URLs or redirects are an actual feature, application-level validation alone is not enough. Validate every destination and route such requests through an egress proxy that blocks internal networks and metadata endpoints, resolves DNS safely and limits redirects and response sizes.
Recommendation: Start with fixed integration identifiers. Introduce free-form targets only when the product truly needs them and enforce the same rules at the network boundary.
Testing authentication flows only on the happy path
Login, password reset, email changes, multi-factor authentication and API tokens form one security lifecycle. Common mistakes include account enumeration, missing rate limits, long-lived links, sessions that remain active after a password change and critical actions without re-authentication.
Combine generic responses, appropriate throttling, short-lived single-use tokens, session rotation and renewed confirmation for sensitive actions. Test expired, reused and altered tokens as well as concurrent sessions. Laravel's database password broker deletes a reset token after successful reset. If two exactly concurrent uses of one token are in scope, the state-changing application flow still needs an atomic single-use boundary.
Route::post('/login', LoginController::class)
->middleware('throttle:login');
RateLimiter::for('login', function (Request $request) {
$key = Str::lower((string) $request->input('email')).'|'.$request->ip();
return Limit::perMinute(5)->by(hash('sha256', $key));
});
if (Auth::attempt($credentials)) {
$request->session()->regenerate();
return redirect()->intended('/dashboard');
}
After a password, email address or MFA factor changes, decide deliberately which credentials remain valid. Laravel provides auth.session and Auth::logoutOtherDevices() for password-backed web sessions; personal API tokens, OAuth tokens and other credential stores need their own revocation rules. Sensitive operations can require recent password confirmation even in an authenticated session.
Recommendation: Model authentication as a state machine. Test every transition, including expired and reused links, account lockout, session fixation, recovery without a second factor and race conditions during token redemption.
Trusting webhooks, jobs and concurrent requests
A webhook is a public endpoint. Verify its signature and allowed timestamp window before processing. Replay protection and idempotency then constrain what a valid repeated message may do. The provider defines the exact signature format. A queued job runs later, potentially on another system; permissions and business state may have changed.
Orders, vouchers, payouts and quotas also need concurrency protection. Transactions, lockForUpdate(), unique constraints and distributed locks solve different parts of the problem. Tests should replay webhooks, alter signatures, execute jobs after a role change and issue concurrent requests.
Verify the raw request body before parsing and persisting the event:
$payload = $request->getContent();
$timestamp = (int) $request->header('X-Webhook-Timestamp');
abort_unless($timestamp > 0 && abs(now()->timestamp - $timestamp) <= 300, 401);
$expected = hash_hmac('sha256', $timestamp.'.'.$payload, config('services.provider.webhook_secret'));
abort_unless(
hash_equals($expected, (string) $request->header('X-Signature')),
401,
);
$event = $request->json()->all();
WebhookEvent::firstOrCreate(
['provider_id' => $event['id']],
['payload' => $event, 'received_at' => now()],
);
A unique index on provider_id is the database-level idempotency boundary. The handler must treat a concurrent unique-constraint violation as an already received event. For state changes that must not overlap, lock the affected row inside a transaction:
DB::transaction(function () use ($order): void {
$lockedOrder = Order::query()->lockForUpdate()->findOrFail($order->id);
if ($lockedOrder->status !== OrderStatus::Pending) {
return;
}
$lockedOrder->markAsPaid();
});
Recommendation: Treat signature verification, replay protection, idempotency and concurrency as separate controls. Test each one independently rather than assuming that a transaction solves all four.
Treating production configuration and secrets as an afterthought
APP_DEBUG=true, an APP_KEY shared between applications, credentials in source control, broad cloud permissions or sensitive logs can compromise otherwise solid code. Config caches and long-running workers also mean that rotated secrets may not become effective everywhere at once.
Store secrets in a protected deployment environment or dedicated secret store. Apply least privilege, disable production debugging, and review logs for tokens, passwords, personal data and full request bodies. Rotation must include web servers, workers and schedulers.
Fail deployment checks early when a dangerous production setting is detected:
if (app()->isProduction() && config('app.debug')) {
throw new RuntimeException('APP_DEBUG must be disabled in production.');
}
if (app()->isProduction() && str_contains(config('app.url'), 'localhost')) {
throw new RuntimeException('APP_URL is not configured for production.');
}
The delivery pipeline should also run tests, static analysis, dependency audits, secret scanning and a production configuration check. Logs need structured redaction for authorization, cookies, reset tokens and personal fields.
Recommendation: Maintain a versioned deployment checklist that covers web processes, queues, schedulers, caches, storage and rollback. Test secret rotation and worker restarts before an incident requires them.
What an experienced review does differently
Secure Laravel development is not ten independent checkboxes. It starts by understanding the product's trust boundaries: users, tenants, roles, background processes, external services, files and deployment. Those boundaries drive policies, validation, isolation and negative tests.
For an existing project, I combine architecture and code analysis with reproducible attack hypotheses. The result answers three questions:
- Which vulnerability is exploitable under realistic conditions?
- Which shared root cause may create additional variants?
- Which regression test prevents the same defect from returning?
Laravel is a strong foundation. Secure business decisions and tests turn it into a dependable platform.
Security architecture instead of isolated patches
The ten mistakes map to four recurring trust boundaries:
| Boundary | Typical failures | Dependable evidence |
|---|---|---|
| Identity and session | login, recovery, email change, MFA, cookies | negative state and credential tests |
| User and data | IDOR/BOLA, tenant leaks, mass assignment | policies plus cross-user and cross-tenant tests |
| Application and untrusted input | SQL, XSS, uploads, SSRF | context-aware output, allowlists and isolated egress |
| Application and operations | webhooks, race conditions, secrets, workers | idempotency, database invariants and deployment checks |
A local correction is useful when it closes the shared cause. Another conditional in the affected controller is rarely sufficient if the same access exists through an API, export, broadcast or queue. The regression test should therefore name the invariant. “No user can receive an object from another tenant” is stronger than “route X fails for ID 43”.
Three focused guides extend this pillar article:
- Secure Laravel authentication and session management covers the account lifecycle, SameSite, CSRF, recovery, email changes, magic links and MFA.
- Separating tenant isolation and authorisation in Laravel examines policies, tenant context, jobs, downloads and negative tests.
- Laravel security audit: a practical checklist connects attack surface, configuration, supply chain, source code, business logic and operations in one review process.
Further reading
- Laravel 13: Authorization
- Laravel 13: Validation
- Laravel 13: File Storage
- Laravel 13: HTTP Client
- OWASP Application Security Verification Standard
- OWASP API Security Top 10
- OWASP Laravel Cheat Sheet
- Securing Laravel: In Depth Articles
Conclusion
These ten mistakes are not an unrelated collection. They emerge at recurring boundaries between identity, data, input and operations. Explicitly modelling those boundaries, following them across every access path and proving them with negative tests creates a Laravel application whose security remains verifiable as the product changes.