How to Secure a Laravel Application: The Definitive Guide
A practical, code-heavy guide to securing Laravel applications. Covers configuration hardening, authentication, input validation, XSS and CSRF protection, API security, security headers, dependency management, and production deployment.
The Foundation: Secure Configuration
Security starts before you write a single line of application code. A misconfigured Laravel app can leak credentials, expose debug information, and invite attackers in, even if your code is perfect.
Environment Configuration
Your .env file contains database credentials, API keys, and encryption secrets. Protecting it is non-negotiable.
Set debug mode to false in production:
APP_DEBUG=false
APP_ENV=production
When APP_DEBUG=true, Laravel displays detailed error pages including your full .env contents, database credentials, and stack traces. This is the single most common Laravel security mistake in production.
Generate a strong application key:
php artisan key:generate
This key encrypts session data, signed URLs, and any data you pass through Crypt::encrypt(). If an attacker obtains this key, they can decrypt all of it. Never commit it to version control. Never reuse it across environments.
Block public access to .env:
Your web server should never serve the .env file. In nginx:
location ~ /\.env {
deny all;
return 404;
}
In Apache, add to .htaccess:
<Files .env>
Order allow,deny
Deny from all
</Files>
Set proper file permissions:
# Directories: readable and executable, not writable by web server
find /var/www/app -type d -exec chmod 755 {} \;
# Files: readable, not writable by web server
find /var/www/app -type f -exec chmod 644 {} \;
# Storage and cache need to be writable
chmod -R 775 storage bootstrap/cache
Config Caching
In production, always cache your configuration:
php artisan config:cache
This compiles all config files into a single cached file and prevents the framework from reading .env at runtime. It is both a performance optimization and a security hardening step.
Authentication and Session Security
Password Hashing
Laravel uses Bcrypt by default. Do not change this to MD5, SHA-1, or any non-adaptive hashing algorithm.
// Laravel handles this automatically when you use:
$user = User::create([
'password' => Hash::make($request->password),
]);
// Verify passwords with:
if (Hash::check($request->password, $user->password)) {
// Authenticated
}
You can increase the Bcrypt work factor in config/hashing.php:
'bcrypt' => [
'rounds' => 12, // Default is 12. Higher = slower but harder to brute force.
],
Two-Factor Authentication
Add 2FA with Laravel Fortify:
// config/fortify.php
'features' => [
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]),
],
For custom implementations, use the pragmarx/google2fa-laravel package:
use PragmaRX\Google2FALaravel\Google2FA;
// Generate a secret for the user
$google2fa = app(Google2FA::class);
$secret = $google2fa->generateSecretKey();
// Verify a one-time password
$valid = $google2fa->verifyKey($user->two_factor_secret, $request->otp);
Session Security
Harden your session configuration in config/session.php:
return [
'driver' => 'database', // or 'redis'. Avoid 'file' in production.
'lifetime' => 120, // Minutes. Keep it short for sensitive apps.
'expire_on_close' => false,
'encrypt' => true, // Encrypt session data at rest
'secure' => true, // Only send cookie over HTTPS
'http_only' => true, // Prevent JavaScript access to session cookie
'same_site' => 'lax', // Protect against CSRF via cross-site requests
];
Regenerate the session ID after authentication to prevent session fixation:
// Laravel does this automatically during login, but if you have
// custom auth logic, call it explicitly:
$request->session()->regenerate();
Input Validation and SQL Injection Prevention
Validate Everything
Never trust user input. Laravel's validation system makes this straightforward:
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email:rfc,dns|unique:users',
'age' => 'required|integer|min:13|max:120',
'website' => 'nullable|url|max:255',
'bio' => 'nullable|string|max:1000',
]);
// Only use $validated data from here on
User::create($validated);
}
For complex validation, use Form Request classes:
class StoreArticleRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => 'required|string|max:255',
'body' => 'required|string|max:50000',
'category_id' => 'required|exists:categories,id',
'tags' => 'array|max:10',
'tags.*' => 'string|max:50',
];
}
}
Preventing SQL Injection
Eloquent and the query builder use parameterized queries by default. These are safe:
// Safe: parameterized automatically
$users = User::where('email', $request->email)->get();
$users = DB::table('users')->where('email', '=', $request->email)->get();
These are dangerous:
// DANGEROUS: raw user input in query string
$users = DB::select("SELECT * FROM users WHERE email = '{$request->email}'");
// DANGEROUS: user input in raw expression without bindings
$users = User::whereRaw("email = '{$request->email}'")->get();
// DANGEROUS: user input in orderByRaw
$users = User::orderByRaw($request->sort_column)->get();
If you must use raw queries, always use parameter bindings:
// Safe: using bindings with raw expressions
$users = User::whereRaw('email = ?', [$request->email])->get();
$users = DB::select('SELECT * FROM users WHERE email = ?', [$request->email]);
// Safe: whitelist approach for column names (which can't be parameterized)
$allowed = ['name', 'email', 'created_at'];
$column = in_array($request->sort, $allowed) ? $request->sort : 'created_at';
$users = User::orderBy($column)->get();
Mass Assignment Protection
Always define $fillable or $guarded on your models:
class User extends Model
{
// Explicit whitelist (preferred)
protected $fillable = ['name', 'email', 'password'];
// OR blacklist approach
// protected $guarded = ['is_admin', 'role'];
}
Never use Model::unguard() or $guarded = [] in production code.
Cross-Site Scripting (XSS) Prevention
Blade Template Escaping
Blade's {{ }} syntax escapes output by default using htmlspecialchars(). Always use double curly braces for user-generated content:
{{-- Safe: escaped output --}}
<p>{{ $user->name }}</p>
<p>{{ $comment->body }}</p>
{{-- DANGEROUS: unescaped output --}}
<p>{!! $user->bio !!}</p>
Only use {!! !!} when you are rendering trusted HTML that you have sanitized yourself. If you need to render user-provided HTML (like a rich text editor), sanitize it first:
use HTMLPurifier;
$config = HTMLPurifier_Config::createDefault();
$config->set('HTML.Allowed', 'p,br,strong,em,ul,ol,li,a[href]');
$purifier = new HTMLPurifier($config);
$clean = $purifier->purify($request->input('body'));
JavaScript Context
Be careful when passing data to JavaScript:
{{-- DANGEROUS: XSS via JavaScript context --}}
<script>
let userName = "{{ $user->name }}"; // Can break out of quotes
</script>
{{-- Safe: use @json directive --}}
<script>
let userName = @json($user->name);
</script>
For passing data to frontend frameworks, use the @json directive or Js::from():
<div x-data="{ user: @json($user) }">
{{-- Alpine.js component with safely encoded data --}}
</div>
CSRF Protection
Laravel includes CSRF protection out of the box. Every POST, PUT, PATCH, and DELETE request must include a valid CSRF token.
In Blade forms:
<form method="POST" action="/profile">
@csrf
<input type="text" name="name" value="{{ old('name') }}">
<button type="submit">Update</button>
</form>
For AJAX requests, include the token in headers:
// Set up Axios (Laravel's default HTTP client) globally
axios.defaults.headers.common['X-CSRF-TOKEN'] = document
.querySelector('meta[name="csrf-token"]')
.getAttribute('content');
Make sure your layout includes the meta tag:
<meta name="csrf-token" content="{{ csrf_token() }}">
Do not disable CSRF protection for web routes. If you need to exclude specific routes (like webhook endpoints), do it explicitly in app/Http/Middleware/VerifyCsrfToken.php:
protected $except = [
'stripe/webhook', // Only exclude specific, authenticated-by-other-means endpoints
];
File Upload Security
File uploads are a common attack vector. Handle them carefully.
public function upload(Request $request)
{
$request->validate([
'document' => [
'required',
'file',
'max:10240', // 10MB max
'mimes:pdf,doc,docx,jpg,png', // Whitelist allowed types
],
]);
// Store outside the public directory
$path = $request->file('document')->store('documents', 'private');
// Generate the filename yourself; never use the original filename
// Laravel's store() method already generates a random name
return $path;
}
Key rules for file uploads:
- Validate file type by MIME, not extension. Use the
mimesormimetypesvalidation rule. - Set a maximum file size. The
maxrule defines size in kilobytes. - Store uploads outside the web root. Use a private disk or store in
storage/app(notpublic). - Never use the original filename. It can contain path traversal characters or executable extensions.
- Serve files through a controller that checks authorization, not via direct URL access.
// Serve private files through a controller
public function download(Document $document)
{
$this->authorize('view', $document);
return Storage::disk('private')->download(
$document->path,
$document->original_name // Safe to use here as the download name
);
}
API Security
Rate Limiting
Define rate limiters in app/Providers/RouteServiceProvider.php (or bootstrap/app.php in Laravel 11+):
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
// Stricter limit for authentication endpoints
RateLimiter::for('login', function (Request $request) {
return Limit::perMinute(5)->by($request->ip());
});
Apply rate limiters to routes:
Route::post('/login', [AuthController::class, 'login'])
->middleware('throttle:login');
Route::middleware('throttle:api')->group(function () {
Route::apiResource('posts', PostController::class);
});
CORS Configuration
Configure CORS in config/cors.php. Be specific:
return [
'paths' => ['api/*'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE'],
'allowed_origins' => ['https://yourfrontend.com'], // Never use '*' with credentials
'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With'],
'exposed_headers' => [],
'max_age' => 86400,
'supports_credentials' => true,
];
Never set allowed_origins to ['*'] if your API uses cookies or authentication headers.
API Authentication with Sanctum
For SPA authentication:
// In your SPA, first request the CSRF cookie:
// GET /sanctum/csrf-cookie
// Then authenticate:
// POST /login with credentials
// All subsequent requests include the session cookie automatically
For token-based API authentication:
// Issue a token
$token = $user->createToken('api-token', ['posts:read', 'posts:write']);
// In API routes, protect with Sanctum
Route::middleware('auth:sanctum')->group(function () {
Route::get('/user', function (Request $request) {
return $request->user();
});
});
// Check token abilities
if ($request->user()->tokenCan('posts:write')) {
// Allowed
}
Security Headers
Security headers tell browsers how to handle your content. They are one of the easiest and most effective security measures you can add.
Create a middleware to set security headers:
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class SecurityHeaders
{
public function handle(Request $request, Closure $next)
{
$response = $next($request);
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('X-XSS-Protection', '0'); // Disabled; CSP is the modern replacement
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
$response->headers->set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none';"
);
// Remove headers that leak server info
header_remove('X-Powered-By');
$response->headers->remove('Server');
return $response;
}
}
Register it in your HTTP kernel or bootstrap file:
// Laravel 10: app/Http/Kernel.php
protected $middleware = [
// ... other middleware
\App\Http\Middleware\SecurityHeaders::class,
];
// Laravel 11+: bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->append(\App\Http\Middleware\SecurityHeaders::class);
})
Content-Security-Policy deserves extra attention. Start strict and loosen only as needed. A misconfigured CSP can break your site, so test in report-only mode first:
$response->headers->set(
'Content-Security-Policy-Report-Only',
"default-src 'self'; report-uri /csp-report"
);
Dependency Management
Composer Audit
Run composer audit to check for known vulnerabilities in your dependencies:
composer audit
Add it to your CI pipeline:
# GitHub Actions example
- name: Security audit
run: composer audit --format=json
This checks your installed packages against the PHP Security Advisories Database.
Keep Dependencies Updated
# See what's outdated
composer outdated --direct
# Update within version constraints
composer update --with-all-dependencies
# Update lock file and verify nothing breaks
composer update
php artisan test
Set up Dependabot or Renovate to automate dependency update PRs. Create .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: "composer"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
Lock File Integrity
Always commit composer.lock and install from it in production:
# Production: install exact versions from lock file
composer install --no-dev --optimize-autoloader --no-interaction
Never run composer update on a production server.
Deployment Security
HTTPS Everywhere
Force HTTPS in Laravel. In app/Providers/AppServiceProvider.php:
public function boot(): void
{
if ($this->app->environment('production')) {
URL::forceScheme('https');
}
}
Configure your .env for HTTPS:
APP_URL=https://yourdomain.com
SESSION_SECURE_COOKIE=true
If you are behind a load balancer or reverse proxy, configure trusted proxies in app/Http/Middleware/TrustProxies.php:
protected $proxies = '*'; // Or specific IPs for better security
protected $headers =
Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO;
Production Checklist
Before every production deployment, verify:
# Debug mode is off
php artisan env | grep APP_DEBUG # Should be false
# Config is cached
php artisan config:cache
# Routes are cached
php artisan route:cache
# Views are cached
php artisan view:cache
# No dev dependencies
composer install --no-dev
# Run security audit
composer audit
Hide Server Information
In nginx, disable server tokens:
server_tokens off;
In Apache:
ServerTokens Prod
ServerSignature Off
In php.ini:
expose_php = Off
Monitoring and Incident Response
Security is not a one-time task. You need ongoing monitoring.
Application-Level Logging
Log security-relevant events:
use Illuminate\Support\Facades\Log;
// Log failed login attempts
protected function sendFailedLoginResponse(Request $request)
{
Log::warning('Failed login attempt', [
'email' => $request->email,
'ip' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
throw ValidationException::withMessages([
'email' => [trans('auth.failed')],
]);
}
// Log privilege escalation attempts
public function update(Request $request, User $user)
{
if ($request->has('is_admin') && !$request->user()->isAdmin()) {
Log::alert('Privilege escalation attempt', [
'user_id' => $request->user()->id,
'target_user' => $user->id,
'ip' => $request->ip(),
]);
abort(403);
}
}
External Monitoring
Internal logging tells you what your application sees. External monitoring tells you what attackers see. These are different things.
StackShield continuously monitors your Laravel application from the outside, checking for:
- Debug mode accidentally left on
- Missing or misconfigured security headers
- Exposed
.envfiles and sensitive endpoints - SSL/TLS misconfigurations
- Cookie security flag issues
- Server information leakage
- And 30+ other external security checks
When a deployment changes your security posture, StackShield alerts your team immediately. This catches the configuration regressions that code-level tools miss.
Security Checklist Summary
| Area | Action | Priority |
|---|---|---|
| Configuration | APP_DEBUG=false, strong APP_KEY, .env protected |
Critical |
| Authentication | Bcrypt hashing, 2FA, session hardening | Critical |
| Input validation | Validate all input, parameterized queries | Critical |
| XSS | Use {{ }} in Blade, sanitize rich text |
High |
| CSRF | @csrf on all forms, token in AJAX headers |
High |
| File uploads | Validate type/size, store outside web root | High |
| API security | Rate limiting, CORS configuration, Sanctum | High |
| Security headers | HSTS, CSP, X-Frame-Options, etc. | High |
| Dependencies | composer audit in CI, Dependabot enabled |
Medium |
| Deployment | HTTPS, cached config, no dev dependencies | Medium |
| Monitoring | Logging, external attack surface monitoring | Medium |
Security is a continuous process. Lock down the fundamentals, automate what you can, and monitor everything.
Check your Laravel app's security posture with StackShield →
Frequently Asked Questions
Is Laravel secure by default?
Laravel ships with strong security defaults: CSRF protection, parameterized queries, Bcrypt password hashing, and encrypted session cookies. However, "secure by default" does not mean "secure without effort." Developers can bypass these protections through misconfiguration, raw queries, unsafe Blade usage, or insecure deployment settings. Laravel gives you the tools; you still need to use them correctly.
How do I prevent SQL injection in Laravel?
Use Eloquent ORM or the query builder for all database queries. Both use parameterized queries that prevent SQL injection automatically. Never pass user input directly into raw query methods like DB::raw(), whereRaw(), or selectRaw() without parameter bindings. If you must use raw expressions, always use the ? placeholder syntax with a bindings array.
Should I use Laravel Sanctum or Passport for API authentication?
Use Sanctum for SPA authentication, mobile app authentication, and simple token-based APIs. It is lighter and simpler. Use Passport only if you need full OAuth2 server functionality, like issuing tokens to third-party applications. For most Laravel applications, Sanctum is the right choice.
How often should I run composer audit?
Run composer audit in every CI/CD pipeline build so no deployment goes out with a known-vulnerable dependency. Additionally, set up automated dependency monitoring with Dependabot or a similar tool to get notified about new vulnerabilities between deployments.
What security headers should every Laravel app have?
At minimum: Strict-Transport-Security (forces HTTPS), X-Content-Type-Options: nosniff (prevents MIME sniffing), X-Frame-Options: DENY (prevents clickjacking), Referrer-Policy: strict-origin-when-cross-origin (limits referrer leakage), and Content-Security-Policy (controls resource loading). Permissions-Policy is also recommended to restrict browser features your app does not use.
Related Security Terms
Related Articles
Laravel Debug Mode in Production: Why It's Dangerous and How to Fix It
Debug mode in production exposes stack traces, database credentials, environment variables, and internal paths. Learn exactly what it reveals, how attackers use it, and how to make sure it never reaches production.
SecurityOWASP Top 10 for Laravel: A Practical Guide
A hands-on mapping of every OWASP Top 10 (2021) category to specific Laravel vulnerabilities, with code examples of what goes wrong and how to fix it.
SecurityIs Your Laravel .env File Exposed? How to Check and Fix It
Your .env file contains database credentials, API keys, and encryption secrets. If it's accessible from the web, attackers already have everything they need. Here's how to check and fix it.