Laravel is one of the most popular PHP frameworks, used by millions of developers worldwide. Its expressive syntax and built-in security features make it a solid foundation - but many teams leave critical default configurations in place or misconfigure key components. Here are the most important security practices to apply to every Laravel application.
1. Protect Your .env File
The .env file contains your database credentials, API keys, and application secrets. It must never be publicly accessible. If your web server is misconfigured, https://yoursite.com/.env returns the file contents directly - this is one of the most commonly exploited misconfigurations in Laravel deployments.
# Nginx - deny access to .env
location ~ /\. {
deny all;
}
# Apache .htaccess
<Files .env>
Order allow,deny
Deny from all
</Files>
Always add .env to your .gitignore. Commit only a .env.example with placeholder values. Never hardcode secrets in source files.
2. Set APP_DEBUG = false in Production
When APP_DEBUG=true, Laravel renders full exception pages with stack traces, SQL queries, environment variables, and file paths. This information is invaluable to an attacker mapping your application internals.
# .env (production)
APP_ENV=production
APP_DEBUG=false
Use a dedicated exception tracking service like Sentry or Bugsnag to capture errors without exposing them to users. Configure a generic 500 error view in resources/views/errors/500.blade.php.
3. Mass Assignment Protection
Mass assignment vulnerabilities occur when user-supplied data is passed directly to Model::create() or Model::update() without filtering. An attacker can add unexpected fields - like is_admin or role - to the request and have them written to the database.
// Vulnerable
User::create($request->all());
// Safe - use $fillable to whitelist
class User extends Model {
protected $fillable = ['name', 'email', 'password'];
// Or use $guarded to blacklist (less safe by default):
// protected $guarded = ['is_admin', 'role'];
}
// Even safer - use validated data from Form Request
User::create($request->validated());
Prefer $fillable over $guarded. Whitelisting is safer than blacklisting - if you add a new sensitive column, $guarded requires you to remember to update it, while $fillable blocks it automatically.
4. SQL Injection via Raw Queries
Laravel's Eloquent ORM and Query Builder use PDO parameterized queries by default and are safe from SQL injection. The risk arises with DB::select(), whereRaw(), orderByRaw(), and similar methods that accept raw SQL.
// Vulnerable
$results = DB::select("SELECT * FROM users WHERE email = '$email'");
// Safe - use bindings
$results = DB::select("SELECT * FROM users WHERE email = ?", [$email]);
// Also safe - named bindings
$results = DB::select(
"SELECT * FROM users WHERE email = :email",
['email' => $email]
);
Audit all whereRaw, selectRaw, orderByRaw, and DB::statement calls in your codebase. Every one of them that incorporates user input is a potential injection point.
5. XSS and Blade Templating
Laravel's Blade templating engine automatically escapes output when you use the {{ }} double-brace syntax, preventing reflected XSS. The danger is the unescaped output syntax {!! !!}, which renders HTML as-is.
{{-- Safe - output is HTML-escaped --}}
{{ $userInput }}
{{-- Dangerous - only use with trusted, sanitized content --}}
{!! $userContent !!}
Use {!! !!} only when rendering HTML you fully control - such as processed Markdown or content from a WYSIWYG editor that has been sanitized server-side with a library like HTMLPurifier or DOMPurify. Never pass raw user input to unescaped output.
6. CSRF Protection
Laravel includes CSRF protection via the VerifyCsrfToken middleware, enabled by default for all web routes. Every form POST must include the CSRF token.
<!-- Blade form -->
<form method="POST" action="/profile">
@csrf
...
</form>
// For AJAX requests - include in meta tag and read via JS
<meta name="csrf-token" content="{{ csrf_token() }}">
Never add routes to the $except array in VerifyCsrfToken without a clear security justification. API routes that use Sanctum token authentication are legitimately exempt - but all browser-session routes must be protected.
7. Secure File Uploads
File uploads are a common attack vector. An attacker who can upload a PHP file to a web-accessible directory and execute it has full code execution on your server.
// Validate file type by MIME, not extension
$request->validate([
'avatar' => 'required|file|mimes:jpg,png,gif|max:2048',
]);
// Store in non-public disk (not public/)
$path = $request->file('avatar')->store('avatars', 'private');
// Serve via controller, not direct URL
return Storage::disk('private')->response($path);
Never store uploaded files in the public/ directory unless you are certain they cannot be executed. Configure your web server to deny PHP execution in upload directories as a defense-in-depth measure.
8. Remove Debug Tools from Production
Laravel Telescope and Debugbar are invaluable in development - in production, they expose request data, query logs, mail content, and exception details to anyone who can access /telescope or triggers the debugbar.
// AppServiceProvider.php
public function register(): void {
if ($this->app->environment('local', 'staging')) {
$this->app->register(TelescopeServiceProvider::class);
}
}
// Or in config/telescope.php:
'enabled' => env('TELESCOPE_ENABLED', false),
If you need Telescope in production for debugging, restrict access to specific IP addresses or authenticated admin users via the gate configuration.
9. Session Security
Configure session cookies to be transmitted only over HTTPS and inaccessible to JavaScript. These settings are in config/session.php or overridden via environment variables.
# .env
SESSION_SECURE_COOKIE=true # HTTPS only
SESSION_HTTP_ONLY=true # Block JS access (default)
SESSION_SAME_SITE=strict # Prevent CSRF via cookie
10. Rate Limiting on Authentication Routes
Without rate limiting, login endpoints are vulnerable to brute force and credential stuffing attacks. Laravel includes a built-in rate limiter configurable per route or route group.
// routes/web.php
Route::middleware(['throttle:login'])->group(function () {
Route::post('/login', [AuthController::class, 'login']);
Route::post('/password/email', [PasswordController::class, 'sendLink']);
});
// RateLimitServiceProvider or bootstrap/app.php
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
Laravel Breeze and Jetstream include rate limiting out of the box. If you are building a custom auth flow, add it explicitly. Five attempts per minute per IP is a reasonable starting point for login endpoints.
11. Route Model Binding and IDOR
Laravel's implicit route model binding is convenient - Route::get('/orders/{order}', ...) automatically resolves $order from the database by ID - but it does not check ownership by default. Any authenticated user who guesses or increments an ID can view another user's order unless the controller explicitly verifies it belongs to them.
// Vulnerable: any authenticated user can view any order by ID
public function show(Order $order) {
return view('orders.show', compact('order'));
}
// Fixed: authorize ownership before returning data
public function show(Order $order) {
$this->authorize('view', $order);
return view('orders.show', compact('order'));
}
This is one of the most common real-world Laravel vulnerabilities precisely because the framework's convenience feature does exactly what it says - bind the model - and nothing more. Authorization is a separate step you have to add.
12. Authorization with Policies and Gates
Laravel's authorization layer centralizes access rules in one place per model, instead of scattering if checks across controllers where they drift out of sync as the app grows. Generate one with php artisan make:policy OrderPolicy --model=Order.
// app/Policies/OrderPolicy.php
public function view(User $user, Order $order): bool {
return $user->id === $order->user_id;
}
A Sanctum-authenticated request proves who the caller is - it does not prove what they're allowed to do. Every controller action that touches another model still needs a policy or gate check; authentication and authorization are separate questions.
13. Sanctum / Passport API Token Security
Sanctum personal access tokens do not expire by default. A token leaked from a mobile app, committed to a public repository, or left in browser localStorage stays valid indefinitely unless someone proactively revokes it.
# config/sanctum.php
'expiration' => 60 * 24 * 7, // expire tokens after 7 days
// Scope tokens to only what they need
$user->createToken('mobile-app', ['orders:read'])->plainTextToken;
// Enforce the scope on protected routes
if (! $request->user()->tokenCan('orders:read')) {
abort(403);
}
Revoke tokens on logout and password change ($user->tokens()->delete()), and prefer scoped abilities over a single all-access token per user - a leaked scoped token limits the blast radius.
14. Queue Security
Data passed into a queued job is serialized and picked up later by a worker running with the application's full privileges, not the original request's session or authorization context. If a job only stores a raw model ID and re-runs an action without re-checking ownership or state at execution time, a since-revoked permission or a changed resource owner won't stop it - the job trusts what was true when it was dispatched, not when it runs.
// Re-verify state inside the job, don't just trust what was dispatched
public function handle(): void {
$order = Order::find($this->orderId);
if (! $order || $order->status !== 'pending') {
return; // state changed since dispatch - do nothing
}
// ... process $order
}
15. CORS Configuration (config/cors.php)
Laravel's default CORS config is meant to be tightened per application, and it's easy to leave it more open than intended. The dangerous combination is 'allowed_origins' => ['*'] together with 'supports_credentials' => true - browsers reject a wildcard origin on credentialed requests, so a common "fix" is listing explicit origins instead, but every origin added later widens the set of sites that can make authenticated cross-origin requests to your API.
// config/cors.php - list only origins that genuinely need credentialed access
'allowed_origins' => ['https://app.yoursite.com'],
'supports_credentials' => true,
16. What Changed in Laravel 11
Laravel 11 removed app/Http/Kernel.php from the default application skeleton - global and route middleware are now registered in bootstrap/app.php's withMiddleware() closure. Security-relevant middleware groups (rate limiting, CSRF exclusions, trusted proxies) configured by following an older tutorial that references Kernel.php will silently do nothing on a fresh Laravel 11 install, since that file no longer exists to register them from.
// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
$middleware->throttleApi();
$middleware->validateCsrfTokens(except: ['webhooks/*']);
})
If your app was upgraded from Laravel 10 rather than started fresh on 11, Kernel.php may still exist and still work - but new security middleware you add should go through bootstrap/app.php going forward, and it's worth confirming which file your app is actually reading from.
Automated Security Scanning
These manual checks catch configuration-level issues. An automated scanner like Shieldome checks runtime behavior - security headers, TLS configuration, exposed paths, cookie flags, and CORS policy - from the attacker's perspective, without requiring code access. Run a scan against your staging environment before every significant deployment.
For deeper coverage: see our guides to Laravel authentication and session security, securing Laravel REST APIs, and common Laravel vulnerabilities and how to fix them.
Building on a different PHP framework? See our Symfony security best practices guide - many of the same underlying PHP issues apply, with Symfony-specific mechanisms (Voters, Doctrine, Twig) covering the equivalent ground.
Try Shieldome's Laravel security scanner - create a free account to run your first scan.