Laravel Security Essentials: Beyond the Defaults
Laravel ships with excellent security defaults, but production applications need additional layers of protection. Here’s what I’ve learned from securing Laravel apps in high-stakes environments.
The Security Onion Model
Think of security as an onion—multiple layers that protect your application core:
- Network Level - Firewalls, VPNs, CDN
- Application Level - Authentication, authorization, validation
- Database Level - Encryption, access controls
- Code Level - Secure coding practices
Beyond Basic Authentication
Multi-Factor Authentication (MFA)
// Using Laravel Fortify with custom MFA
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
class CustomTwoFactorAuth
{
public function enable(Request $request)
{
$user = $request->user();
// Generate backup codes
$backupCodes = collect(range(1, 8))
->map(fn () => Str::random(10))
->toArray();
$user->forceFill([
'two_factor_recovery_codes' => encrypt(json_encode($backupCodes)),
])->save();
// Your MFA logic here
}
}
JWT with Refresh Tokens
class JWTAuthService
{
public function createTokenPair($user)
{
$accessToken = $this->createAccessToken($user);
$refreshToken = $this->createRefreshToken($user);
// Store refresh token hash in database
$user->refresh_tokens()->create([
'token_hash' => hash('sha256', $refreshToken),
'expires_at' => now()->addDays(30),
]);
return compact('accessToken', 'refreshToken');
}
}
Database Security Patterns
Attribute Encryption
use Illuminate\Database\Eloquent\Casts\Attribute;
class User extends Model
{
protected function socialSecurityNumber(): Attribute
{
return Attribute::make(
get: fn ($value) => decrypt($value),
set: fn ($value) => encrypt($value),
);
}
}
Query Builder Security
// ❌ Vulnerable to injection
DB::select("SELECT * FROM users WHERE role = '{$role}'");
// ✅ Parameterized queries
DB::select('SELECT * FROM users WHERE role = ?', [$role]);
// ✅ Even better with Query Builder
User::where('role', $role)->get();
API Security Deep Dive
Rate Limiting Strategies
// Custom rate limiter in RouteServiceProvider
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by(
$request->user()?->id ?: $request->ip()
)->response(function () {
return response()->json([
'message' => 'Too many requests. Please slow down.'
], 429);
});
});
CORS Configuration
// config/cors.php
return [
'paths' => ['api/*'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE'],
'allowed_origins' => [
'https://yourapp.com',
'https://admin.yourapp.com',
],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,
];
Input Validation & Sanitization
Custom Validation Rules
class SecurePasswordRule implements Rule
{
public function passes($attribute, $value)
{
return preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{12,}$/', $value);
}
public function message()
{
return 'Password must be at least 12 characters with mixed case, numbers, and symbols.';
}
}
File Upload Security
class FileUploadRequest extends FormRequest
{
public function rules(): array
{
return [
'file' => [
'required',
'file',
'max:2048', // 2MB
'mimes:jpg,jpeg,png,pdf',
new NoMaliciousContent,
],
];
}
}
class NoMaliciousContent implements Rule
{
public function passes($attribute, $value)
{
// Check file signatures
$allowedSignatures = [
'jpg' => 'ffd8ffe0',
'png' => '89504e47',
'pdf' => '25504446',
];
$fileSignature = bin2hex(fread(fopen($value->path(), 'r'), 4));
return in_array($fileSignature, $allowedSignatures);
}
}
Security Headers Middleware
class SecurityHeadersMiddleware
{
public function handle($request, Closure $next)
{
$response = $next($request);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
// CSP Header
$csp = "default-src 'self'; script-src 'self' 'unsafe-inline' cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' fonts.googleapis.com; font-src 'self' fonts.gstatic.com; img-src 'self' data: https:;";
$response->headers->set('Content-Security-Policy', $csp);
return $response;
}
}
Logging & Monitoring
Security Event Logging
class SecurityLogger
{
public static function logFailedLogin($email, $ip)
{
Log::channel('security')->warning('Failed login attempt', [
'email' => $email,
'ip' => $ip,
'user_agent' => request()->userAgent(),
'timestamp' => now(),
]);
}
public static function logSuspiciousActivity($user, $activity)
{
Log::channel('security')->alert('Suspicious activity detected', [
'user_id' => $user->id,
'activity' => $activity,
'ip' => request()->ip(),
'timestamp' => now(),
]);
// Trigger alerts
event(new SuspiciousActivityDetected($user, $activity));
}
}
Key Takeaways
- Defense in Depth - Layer your security controls
- Validate Everything - Never trust user input
- Principle of Least Privilege - Give minimum required access
- Monitor & Log - You can’t secure what you can’t see
- Regular Updates - Keep dependencies current
Tools I Recommend
- Laravel Security Checker - Scan for known vulnerabilities
- OWASP ZAP - Web application security testing
- Snyk - Dependency vulnerability scanning
- Laravel Telescope - Application monitoring (dev only!)
Security isn’t a feature you add later—it’s a mindset you adopt from day one. These patterns have helped me build and maintain secure Laravel applications in production environments.
What security practices have you found most effective? Let me know on LinkedIn.