Securing Your Laravel Application
Security isn't an afterthought—it's the foundation upon which trustworthy applications are built. Laravel provides a robust set of security features out of the box, but understanding how to properly implement and extend these protections is crucial for every developer. In this comprehensive guide, we'll explore everything you need to know to build Secure Laravel applications that can withstand modern threats.
Whether you're building a simple blog or a complex enterprise application handling sensitive data, the security principles and implementations covered here will help you protect your users, your data, and your reputation. We'll dive deep into authentication, authorization, input validation, encryption, and the many layers of defense that make Laravel one of the most secure PHP frameworks available.
- Laravel's built-in security features and how they work
- Protecting against CSRF, XSS, and SQL injection attacks
- Implementing robust authentication and authorization
- Securing APIs with Sanctum and rate limiting
- Encryption, hashing, and secure data storage
- Security headers and production hardening
- Security audit checklist and best practices
Understanding Common Web Vulnerabilities
Before diving into Laravel's security features, let's understand the threats we're protecting against. The OWASP Top 10 represents the most critical web application security risks, and Laravel provides defenses against most of them out of the box.
Laravel provides multiple layers of defense against common web vulnerabilities
CSRF Protection: Preventing Cross-Site Request Forgery
Cross-Site Request Forgery (CSRF) attacks trick authenticated users into performing unwanted actions. Laravel protects against this by generating and validating unique tokens for each user session.
How CSRF Protection Works
Implementing CSRF Protection
Laravel's CSRF protection is enabled by default for all POST, PUT, PATCH, and DELETE requests. Here's how to implement it properly:
<form method="POST" action="/profile">
@csrf
<div class="form-group">
<label for="name">Name</label>
<input type="text" name="name" id="name" value="{{ old('name') }}">
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" name="email" id="email" value="{{ old('email') }}">
</div>
<button type="submit">Update Profile</button>
</form>
<!-- For DELETE requests, use method spoofing -->
<form method="POST" action="/posts/{{ $post->id }}">
@csrf
@method('DELETE')
<button type="submit">Delete Post</button>
</form>
For AJAX requests, you'll need to include the CSRF token in your headers:
// Set up CSRF token for all AJAX requests
const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
// Using Fetch API
fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': token,
'Accept': 'application/json'
},
body: JSON.stringify({ title: 'New Post', content: 'Content here' })
})
.then(response => response.json())
.then(data => console.log(data));
// Using Axios (auto-configured in Laravel)
axios.defaults.headers.common['X-CSRF-TOKEN'] = token;
axios.post('/api/posts', {
title: 'New Post',
content: 'Content here'
});
Add the CSRF meta tag to your layout's <head> section: <meta name="csrf-token" content="{{ csrf_token() }}">. This makes the token easily accessible for JavaScript frameworks.
XSS Prevention: Escaping Output
Cross-Site Scripting (XSS) attacks inject malicious scripts into web pages viewed by other users. Laravel's Blade templating engine automatically escapes output, but you need to understand when and how to use it correctly.
Auto-Escaped Output
<!-- SAFE: Automatically escapes HTML entities -->
<p>Welcome, {{ $user->name }}</p>
<!-- Input: <script>alert('XSS')</script> -->
<!-- Output: <script>alert('XSS')</script> -->
<!-- Safe in attributes too -->
<input value="{{ $userInput }}">
<!-- Use e() helper in PHP -->
<?php echo e($userInput); ?>
Dangerous - Raw Output
<!-- DANGEROUS: Never use with user input! -->
{!! $content !!}
<!-- Only use for trusted content like: -->
{!! $page->trusted_html !!} <!-- Admin-created content -->
{!! $markdown->render() !!} <!-- Sanitized markdown -->
<!-- NEVER do this: -->
{!! $request->input('comment') !!} <!-- XSS vulnerability! -->
{!! $user->bio !!} <!-- User-controlled = dangerous -->
Sanitized HTML
<!-- Use a sanitization library for user HTML -->
// Install: composer require mews/purifier
// config/purifier.php
'HTML.Allowed' => 'p,b,i,u,a[href],ul,ol,li,br',
// In your code:
use Mews\Purifier\Facades\Purifier;
$cleanHtml = Purifier::clean($userHtml);
// In Blade:
{!! Purifier::clean($post->content) !!}
// Or create a custom Blade directive
// AppServiceProvider:
Blade::directive('purify', function ($expression) {
return "<?php echo \Purifier::clean($expression); ?>";
});
// Usage: @purify($post->content)
SQL Injection Prevention
SQL Injection occurs when attackers insert malicious SQL code through user inputs. Laravel's Eloquent ORM and Query Builder use PDO parameter binding, which automatically protects against SQL injection.
Safe Practices
Eloquent ORM, Query Builder with bindings, parameterized raw queries
Dangerous Practices
Raw queries with concatenated user input, unvalidated orderBy columns
// ✅ SAFE: Eloquent ORM (always parameterized)
$users = User::where('email', $request->email)->get();
$user = User::find($id);
// ✅ SAFE: Query Builder with bindings
$users = DB::table('users')
->where('email', '=', $request->email)
->where('status', 'active')
->get();
// ✅ SAFE: Raw query with bindings
$users = DB::select(
'SELECT * FROM users WHERE email = ? AND role = ?',
[$request->email, 'admin']
);
// ✅ SAFE: Named bindings
$users = DB::select(
'SELECT * FROM users WHERE email = :email',
['email' => $request->email]
);
// ✅ SAFE: whereRaw with bindings
$users = User::whereRaw('LOWER(email) = ?', [strtolower($email)])->get();
// ❌ DANGEROUS: String concatenation
$users = DB::select("SELECT * FROM users WHERE email = '$email'");
// ❌ DANGEROUS: Direct variable interpolation
$users = DB::select("SELECT * FROM users WHERE id = {$id}");
// ❌ DANGEROUS: Unvalidated column names in orderBy
$users = User::orderBy($request->sort_column)->get();
// ✅ SAFE: Whitelist allowed columns
$allowedColumns = ['name', 'email', 'created_at'];
$column = in_array($request->sort_column, $allowedColumns)
? $request->sort_column
: 'created_at';
$users = User::orderBy($column)->get();
// ❌ DANGEROUS: whereRaw without bindings
User::whereRaw("email = '$email'")->get();
// ✅ SAFE: whereRaw with bindings
User::whereRaw('email = ?', [$email])->get();
Never concatenate user input directly into SQL queries, even when using Laravel. The Query Builder and Eloquent are safe only when you use their parameter binding features correctly.
Authentication Best Practices
Laravel provides multiple authentication systems: the built-in authentication scaffolding, Laravel Breeze, Laravel Jetstream, and Laravel Fortify. Regardless of which you choose, these security practices apply.
Password Security
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class RegisterController extends Controller
{
public function store(Request $request)
{
// Strong password validation
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users'],
'password' => [
'required',
'confirmed',
Password::min(8)
->letters() // At least one letter
->mixedCase() // Upper and lowercase
->numbers() // At least one number
->symbols() // At least one symbol
->uncompromised(), // Not in data breaches (HIBP API)
],
]);
// Hash password using bcrypt (default) or argon2
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
// Never store plain-text passwords!
// ❌ 'password' => $validated['password']
return redirect()->route('login')
->with('success', 'Account created successfully!');
}
}
Session Security
Configure session security in config/session.php:
return [
// Use database or redis for production
'driver' => env('SESSION_DRIVER', 'database'),
// Session lifetime in minutes
'lifetime' => env('SESSION_LIFETIME', 120),
// Expire session on browser close
'expire_on_close' => false,
// Encrypt session data
'encrypt' => true,
// Cookie settings
'cookie' => env('SESSION_COOKIE', 'laravel_session'),
// Restrict cookie to HTTPS only
'secure' => env('SESSION_SECURE_COOKIE', true),
// Prevent JavaScript access to cookie
'http_only' => true,
// SameSite cookie attribute
'same_site' => 'lax', // or 'strict' for more protection
];
Multi-Factor Authentication
Implement 2FA using Laravel Fortify or a package like pragmarx/google2fa-laravel:
// Install: composer require pragmarx/google2fa-laravel
use PragmaRX\Google2FALaravel\Support\Authenticator;
use PragmaRX\Google2FA\Google2FA;
class TwoFactorController extends Controller
{
public function enable(Request $request)
{
$google2fa = new Google2FA();
// Generate secret key
$secret = $google2fa->generateSecretKey();
// Store encrypted in database
$request->user()->update([
'two_factor_secret' => encrypt($secret),
]);
// Generate QR code URL
$qrCodeUrl = $google2fa->getQRCodeUrl(
config('app.name'),
$request->user()->email,
$secret
);
return view('2fa.enable', compact('qrCodeUrl', 'secret'));
}
public function verify(Request $request)
{
$request->validate(['code' => 'required|digits:6']);
$google2fa = new Google2FA();
$secret = decrypt($request->user()->two_factor_secret);
$valid = $google2fa->verifyKey($secret, $request->code);
if ($valid) {
$request->user()->update(['two_factor_enabled' => true]);
return redirect()->route('profile')
->with('success', '2FA enabled successfully!');
}
return back()->withErrors(['code' => 'Invalid verification code']);
}
}
Authorization with Gates and Policies
Authorization determines what authenticated users can do. Laravel provides two primary mechanisms: Gates for simple checks and Policies for model-based authorization.
// Create policy: php artisan make:policy PostPolicy --model=Post
namespace App\Policies;
use App\Models\Post;
use App\Models\User;
class PostPolicy
{
/**
* Determine if user can view any posts.
*/
public function viewAny(User $user): bool
{
return true; // Everyone can view posts list
}
/**
* Determine if user can view the post.
*/
public function view(User $user, Post $post): bool
{
// Published posts are public, drafts only for owner
return $post->is_published || $user->id === $post->user_id;
}
/**
* Determine if user can create posts.
*/
public function create(User $user): bool
{
return $user->hasVerifiedEmail();
}
/**
* Determine if user can update the post.
*/
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id || $user->isAdmin();
}
/**
* Determine if user can delete the post.
*/
public function delete(User $user, Post $post): bool
{
return $user->id === $post->user_id || $user->isAdmin();
}
/**
* Perform pre-authorization checks (admin bypass).
*/
public function before(User $user, string $ability): ?bool
{
if ($user->isSuperAdmin()) {
return true; // Super admins can do anything
}
return null; // Fall through to specific checks
}
}
class PostController extends Controller
{
public function __construct()
{
// Authorize all resource actions automatically
$this->authorizeResource(Post::class, 'post');
}
public function show(Post $post)
{
// Authorization already handled by authorizeResource
return view('posts.show', compact('post'));
}
public function update(Request $request, Post $post)
{
// Manual authorization (if not using authorizeResource)
$this->authorize('update', $post);
$post->update($request->validated());
return redirect()->route('posts.show', $post);
}
public function destroy(Post $post)
{
$this->authorize('delete', $post);
$post->delete();
return redirect()->route('posts.index')
->with('success', 'Post deleted successfully');
}
}
// In Blade templates:
@can('update', $post)
<a href="{{ route('posts.edit', $post) }}">Edit</a>
@endcan
@cannot('delete', $post)
<p>You cannot delete this post.</p>
@endcannot
@canany(['update', 'delete'], $post)
<div class="admin-actions">...</div>
@endcanany
Input Validation & Sanitization
Never trust user input. Laravel's validation system provides a robust way to ensure data integrity and security.
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\File;
use Illuminate\Validation\Rules\Password;
class StoreUserRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Or check permissions here
}
public function rules(): array
{
return [
// String validation
'name' => [
'required',
'string',
'min:2',
'max:100',
'regex:/^[\pL\s\-]+$/u', // Letters, spaces, hyphens only
],
// Email with DNS check
'email' => [
'required',
'email:rfc,dns',
'unique:users,email',
'max:255',
],
// Strong password
'password' => [
'required',
'confirmed',
Password::min(12)
->letters()
->mixedCase()
->numbers()
->symbols()
->uncompromised(3), // Check against 3+ breaches
],
// URL validation
'website' => [
'nullable',
'url:http,https', // Only http/https
'max:500',
],
// Safe file upload
'avatar' => [
'nullable',
File::image()
->max(2 * 1024) // 2MB max
->dimensions(
minWidth: 100,
minHeight: 100,
maxWidth: 2000,
maxHeight: 2000
),
],
// Integer in range
'age' => ['nullable', 'integer', 'min:13', 'max:120'],
// Enum/whitelist values
'role' => ['required', 'in:user,moderator,admin'],
// Array validation
'tags' => ['nullable', 'array', 'max:10'],
'tags.*' => ['string', 'max:50', 'alpha_dash'],
// JSON data
'settings' => ['nullable', 'json'],
// Date validation
'birth_date' => [
'nullable',
'date',
'before:today',
'after:1900-01-01',
],
];
}
public function messages(): array
{
return [
'name.regex' => 'Name can only contain letters, spaces, and hyphens.',
'email.dns' => 'Please provide a valid email with an active domain.',
'password.uncompromised' => 'This password has been exposed in data breaches. Please choose a different one.',
];
}
/**
* Sanitize input before validation
*/
protected function prepareForValidation(): void
{
$this->merge([
'name' => strip_tags($this->name),
'email' => strtolower(trim($this->email)),
'website' => $this->website ? trim($this->website) : null,
]);
}
}
Rate Limiting & Throttling
Rate limiting protects your application from brute force attacks and API abuse. Laravel provides flexible rate limiting through the RateLimiter facade.
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
// Default API rate limiter
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by(
$request->user()?->id ?: $request->ip()
);
});
// Strict login rate limiting (prevent brute force)
RateLimiter::for('login', function (Request $request) {
$email = strtolower($request->input('email'));
return [
// 5 attempts per email per minute
Limit::perMinute(5)->by($email),
// 3 attempts per IP per minute
Limit::perMinute(3)->by($request->ip()),
];
});
// Password reset limiting
RateLimiter::for('password-reset', function (Request $request) {
return Limit::perHour(3)->by(
$request->input('email') . '|' . $request->ip()
);
});
// Expensive operations (exports, reports)
RateLimiter::for('exports', function (Request $request) {
return $request->user()->isPremium()
? Limit::perHour(100)
: Limit::perHour(10);
});
// Global rate limit with custom response
RateLimiter::for('global', function (Request $request) {
return Limit::perMinute(1000)
->by($request->ip())
->response(function () {
return response()->json([
'error' => 'Too many requests. Please slow down.',
'retry_after' => 60
], 429);
});
});
}
// routes/web.php
Route::post('/login', [LoginController::class, 'store'])
->middleware('throttle:login');
Route::post('/forgot-password', [PasswordResetController::class, 'store'])
->middleware('throttle:password-reset');
// routes/api.php
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::apiResource('posts', PostController::class);
// Custom rate limit for specific endpoint
Route::get('/export', [ExportController::class, 'index'])
->middleware('throttle:exports');
});
Encryption & Secure Data Storage
Laravel uses AES-256-CBC encryption by default. Sensitive data should always be encrypted before storage.
use Illuminate\Support\Facades\Crypt;
use Illuminate\Contracts\Encryption\DecryptException;
class SecureDataService
{
/**
* Store sensitive data encrypted
*/
public function storeSensitiveData(User $user, string $ssn): void
{
$user->update([
'ssn_encrypted' => Crypt::encryptString($ssn),
]);
}
/**
* Retrieve and decrypt sensitive data
*/
public function getSensitiveData(User $user): ?string
{
if (!$user->ssn_encrypted) {
return null;
}
try {
return Crypt::decryptString($user->ssn_encrypted);
} catch (DecryptException $e) {
// Log the error, but don't expose details
Log::error('Decryption failed for user', ['user_id' => $user->id]);
return null;
}
}
}
// Using Encrypted Casts (Laravel 8+)
class User extends Model
{
protected $casts = [
'ssn' => 'encrypted', // Auto encrypt/decrypt
'api_key' => 'encrypted',
'settings' => 'encrypted:array', // Encrypted JSON
];
}
// Now encryption is automatic:
$user->ssn = '123-45-6789'; // Encrypted on save
echo $user->ssn; // Decrypted on access
The APP_KEY in your .env file is used for all encryption. If it's compromised, all encrypted data can be decrypted. Never commit your .env file to version control, and rotate keys periodically with php artisan key:generate (requires re-encrypting existing data).
Security Headers
HTTP security headers add additional layers of protection. Configure these in middleware or your web server.
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class SecurityHeaders
{
public function handle(Request $request, Closure $next)
{
$response = $next($request);
// Prevent XSS attacks
$response->headers->set(
'X-XSS-Protection',
'1; mode=block'
);
// Prevent clickjacking
$response->headers->set(
'X-Frame-Options',
'SAMEORIGIN'
);
// Prevent MIME type sniffing
$response->headers->set(
'X-Content-Type-Options',
'nosniff'
);
// Referrer policy
$response->headers->set(
'Referrer-Policy',
'strict-origin-when-cross-origin'
);
// Permissions policy
$response->headers->set(
'Permissions-Policy',
'camera=(), microphone=(), geolocation=()'
);
// Content Security Policy (customize for your app)
if (app()->environment('production')) {
$response->headers->set(
'Content-Security-Policy',
"default-src 'self'; " .
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " .
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " .
"font-src 'self' https://fonts.gstatic.com; " .
"img-src 'self' data: https:; " .
"connect-src 'self'; " .
"frame-ancestors 'none';"
);
}
// Strict Transport Security (HTTPS only)
if ($request->secure()) {
$response->headers->set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
}
return $response;
}
}
Secure File Uploads
File uploads are a common attack vector. Always validate, sanitize, and store files securely.
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class FileUploadController extends Controller
{
private array $allowedMimeTypes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'application/pdf',
];
private array $allowedExtensions = [
'jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf'
];
public function store(Request $request)
{
$request->validate([
'file' => [
'required',
'file',
'max:10240', // 10MB max
'mimes:jpeg,png,gif,webp,pdf',
],
]);
$file = $request->file('file');
// Double-check MIME type (can be spoofed, but adds a layer)
if (!in_array($file->getMimeType(), $this->allowedMimeTypes)) {
abort(422, 'Invalid file type.');
}
// Verify extension
$extension = strtolower($file->getClientOriginalExtension());
if (!in_array($extension, $this->allowedExtensions)) {
abort(422, 'Invalid file extension.');
}
// Generate random filename (prevent path traversal & overwrites)
$filename = Str::uuid() . '.' . $extension;
// Store outside web root (use 'local' disk, not 'public')
$path = $file->storeAs(
'uploads/' . date('Y/m'),
$filename,
'local' // Not publicly accessible
);
// If it needs to be public, use signed URLs
// $url = Storage::temporaryUrl($path, now()->addHour());
// Store metadata in database
$upload = Upload::create([
'user_id' => auth()->id(),
'original_name' => $file->getClientOriginalName(),
'path' => $path,
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
]);
return response()->json([
'id' => $upload->id,
'message' => 'File uploaded successfully',
]);
}
/**
* Serve files securely through controller
*/
public function show(Upload $upload)
{
// Authorization check
$this->authorize('view', $upload);
// Stream file from private storage
return Storage::response($upload->path);
}
}
Security Audit Checklist
Use this checklist to audit your Laravel application's security posture:
APP_DEBUG=false in production, APP_ENV=production
AppServiceProvider or web server config
@csrf, VerifyCsrfToken middleware enabled
{{ }} for output, sanitizing user HTML
Hash::make(), strong password validation rules
$fillable or $guarded defined on all models
composer audit regularly, keep packages current
Production Hardening
# Application
APP_ENV=production
APP_DEBUG=false
APP_URL=https://yourapp.com
# Security
SESSION_SECURE_COOKIE=true
SESSION_HTTP_ONLY=true
SANCTUM_STATEFUL_DOMAINS=yourapp.com
# Logging (don't log sensitive data)
LOG_CHANNEL=stack
LOG_LEVEL=warning
# Cache config and routes
# Run these during deployment:
# php artisan config:cache
# php artisan route:cache
# php artisan view:cache
#!/bin/bash
# deploy.sh
# Exit on error
set -e
# Put app in maintenance mode
php artisan down --secret="your-bypass-token"
# Pull latest code
git pull origin main
# Install dependencies (no dev)
composer install --no-dev --optimize-autoloader
# Run migrations
php artisan migrate --force
# Clear and cache config
php artisan config:clear
php artisan config:cache
# Cache routes
php artisan route:cache
# Cache views
php artisan view:cache
# Clear application cache
php artisan cache:clear
# Restart queue workers
php artisan queue:restart
# Security audit
composer audit
# Bring app back online
php artisan up
echo "Deployment complete!"
Key Takeaways
Security Principles
- Defense in Depth: Multiple layers of security are better than relying on one
- Least Privilege: Give users only the permissions they need
- Never Trust User Input: Always validate and sanitize
- Keep Updated: Regularly update Laravel and dependencies
- Monitor & Log: Track suspicious activity and security events
- Encrypt Sensitive Data: At rest and in transit
Security is an ongoing process, not a one-time setup. Regularly audit your application, stay informed about new vulnerabilities, and follow Laravel's security best practices. The framework provides excellent tools—your job is to use them correctly.
By implementing the security measures outlined in this guide, you'll have a solid foundation for building applications that protect both your users and your business. Remember: security is not about being paranoid; it's about being prepared.
