--- name: laravel-security description: Laravel security best practices — authentication, authorization, Eloquent safety, CSRF, XSS prevention, API security, and secure deployment configurations. origin: ECC --- # Laravel Security Best Practices Comprehensive security guidelines for Laravel applications to protect against common vulnerabilities. ## When to Activate - Setting up Laravel authentication and authorization (Sanctum, Passport, Jetstream, Breeze) - Implementing user roles, permissions, and policies - Configuring production security settings and environment variables - Reviewing Laravel applications for security vulnerabilities - Deploying Laravel applications to production - Writing secure Eloquent queries and migrations ## Production Configuration ### Essential Production Settings ```php // config/app.php 'env' => env('APP_ENV', 'production'), 'debug' => (bool) env('APP_DEBUG', false), // CRITICAL: Never true in production 'key' => env('APP_KEY'), // Must be set: php artisan key:generate // config/session.php 'secure' => env('SESSION_SECURE_COOKIE', true), 'http_only' => true, 'same_site' => 'lax', // Verify APP_KEY is set at boot // bootstrap/app.php or a service provider if (empty(config('app.key'))) { throw new RuntimeException('APP_KEY is not set. Run: php artisan key:generate'); } ``` ### Environment File Security ```bash # NEVER commit .env to version control # .gitignore already includes .env by default # Use .env.example with placeholders instead DB_PASSWORD= APP_KEY= SANCTUM_TOKEN_PREFIX= # Validate required variables at boot // In AppServiceProvider::boot() $requiredKeys = ['app.key', 'database.connections.mysql.database', 'database.connections.mysql.username']; foreach ($requiredKeys as $key) { if (empty(config($key))) { throw new RuntimeException("Missing required config key: {$key}"); } } ``` ### HTTPS Enforcement ```php // AppServiceProvider::boot() or middleware if (app()->environment('production')) { URL::forceScheme('https'); request()->server->set('HTTPS', 'on'); } // config/app.php for trusted proxies (load balancers) // Use specific IP ranges — * trusts all, allowing X-Forwarded-* spoofing // AWS: '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16' 'trusted_proxies' => ['10.0.0.0/8', '172.16.0.0/12'], // Force HTTPS in production via middleware // app/Http/Middleware/ForceHttps.php public function handle($request, Closure $next) { if (!$request->secure() && app()->environment('production')) { return redirect()->secure($request->getRequestUri()); } return $next($request); } ``` ## Authentication ### Sanctum (API Token Authentication) ```php // config/sanctum.php 'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( '%s%s', 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', env('APP_URL') ? ',' . parse_url(env('APP_URL'), PHP_URL_HOST) : '' ))); 'expiration' => 60 * 24, // Token expiration in minutes (null = never) 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), // Issuing tokens with abilities $token = $user->createToken('api-token', ['read', 'write'])->plainTextToken; // Validate abilities on routes Route::middleware('auth:sanctum')->group(function () { Route::get('/orders', function () { // User must have 'read' ability abort_unless(Auth::user()->tokenCan('read'), 403); // ... })->middleware('abilities:read'); Route::post('/orders', function () { // User must have 'write' ability abort_unless(Auth::user()->tokenCan('write'), 403); // ... })->middleware('abilities:write'); }); ``` ### Password Security ```php // config/hashing.php // Default is bcrypt. Argon2id is stronger. 'bcrypt' => [ 'rounds' => env('BCRYPT_ROUNDS', 12), // Increase for stronger hashing ], 'argon' => [ 'memory' => 65536, 'threads' => 4, 'time' => 4, ], // Password validation in RegisterRequest public function rules(): array { return [ 'password' => [ 'required', 'confirmed', Password::min(12) ->letters() ->mixedCase() ->numbers() ->symbols() ->uncompromised(), // Checks haveibeenpwned ], ]; } // Rate limit login attempts // App\Http\Controllers\Auth\AuthenticatedSessionController protected function authenticated(Request $request, $user) { if ($user->wasRecentlyLockedOut()) { // Notify user of suspicious login $user->notify(new SuspiciousLoginNotification($request->ip())); } } ``` ### Session Management ```php // config/session.php 'driver' => env('SESSION_DRIVER', 'database'), // database/redis > file 'lifetime' => env('SESSION_LIFETIME', 120), 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), 'encrypt' => env('SESSION_ENCRYPT', false), // Regenerate session on login // App\Http\Controllers\Auth\AuthenticatedSessionController public function store(LoginRequest $request): RedirectResponse { $request->authenticate(); $request->session()->regenerate(); // CRITICAL: prevents session fixation return redirect()->intended(RouteServiceProvider::HOME); } // Invalidate session on logout public function destroy(Request $request): RedirectResponse { Auth::guard('web')->logout(); $request->session()->invalidate(); $request->session()->regenerateToken(); return redirect('/'); } ``` ## Authorization ### Gates ```php // App\Providers\AuthServiceProvider use App\Models\Post; use App\Models\User; use Illuminate\Support\Facades\Gate; public function boot(): void { Gate::define('update-post', function (User $user, Post $post): bool { return $user->id === $post->user_id; }); Gate::define('publish-post', function (User $user): bool { return $user->role === 'editor' || $user->role === 'admin'; }); // Using before() for super-admin override Gate::before(function (User $user, string $ability): ?bool { if ($user->role === 'super-admin') { return true; // Grants all abilities } return null; // Fall through to normal checks }); } // Usage in controllers public function update(Request $request, Post $post): RedirectResponse { Gate::authorize('update-post', $post); // Or: $this->authorize('update-post', $post); // Or: abort_unless(Auth::user()->can('update-post', $post), 403); // ... } ``` ### Policies ```php // App\Policies\PostPolicy class PostPolicy { use HandlesAuthorization; public function viewAny(?User $user): bool { return true; // Public listing } public function view(?User $user, Post $post): bool { return $post->is_published || ($user && $user->id === $post->user_id); } public function create(User $user): bool { return $user->hasVerifiedEmail(); // Must verify email first } public function update(User $user, Post $post): bool { return $user->id === $post->user_id; } public function delete(User $user, Post $post): bool { return $user->id === $post->user_id && $post->created_at->diffInDays(now()) <= 30; } public function restore(User $user, Post $post): bool { return $user->role === 'admin'; } public function forceDelete(User $user, Post $post): bool { return $user->role === 'super-admin'; } } // Register in AuthServiceProvider protected $policies = [ Post::class => PostPolicy::class, ]; // Controller usage public function show(Post $post): View { $this->authorize('view', $post); return view('posts.show', compact('post')); } // Blade usage @can('update', $post) Edit @endcan @cannot('update', $post) You cannot edit this post @endcannot ``` ### Middleware Authorization ```php // Using middleware in routes Route::put('/posts/{post}', [PostController::class, 'update']) ->middleware('can:update,post'); Route::get('/posts/create', [PostController::class, 'create']) ->middleware('can:create,App\Models\Post'); // Custom authorization middleware // app/Http/Middleware/CheckRole.php class CheckRole { public function handle(Request $request, Closure $next, string $role): mixed { if (!$request->user() || $request->user()->role !== $role) { abort(403, 'Unauthorized. This area requires role: ' . $role); } return $next($request); } } // Register in Kernel protected $routeMiddleware = [ 'role' => \App\Http\Middleware\CheckRole::class, ]; // Route usage Route::middleware(['auth', 'role:admin'])->group(function () { Route::get('/admin', [AdminController::class, 'index']); }); ``` ## Eloquent Security ### Mass Assignment Protection ```php // BAD: $guarded = [] allows ALL columns to be mass-assigned // NEVER use $guarded = [] in production // GOOD: Whitelist fillable attributes final class User extends Authenticatable { protected $fillable = [ 'name', 'email', 'phone', 'avatar', ]; // NEVER add 'role', 'is_admin', 'is_verified' here } // GOOD: Explicitly control which fields can be filled in requests public function store(StoreUserRequest $request): RedirectResponse { $user = User::create($request->safe()->only([ 'name', 'email', 'phone', 'avatar' ])); // $request->safe() uses validated data only // $request->only() is NOT safe on its own without validation rules } // BAD: Creating a user with request data directly User::create($request->all()); // VULNERABLE to mass assignment! // BETTER: Use DTOs for creation $user = User::create($request->validated()); // Only validated fields ``` ### SQL Injection Prevention ```php // GOOD: Eloquent automatically parameterizes queries User::where('email', $userInput)->first(); User::whereRaw('email = ?', [$userInput])->first(); // GOOD: Query Builder also parameterizes DB::table('users')->where('email', $userInput)->first(); DB::select('SELECT * FROM users WHERE email = ?', [$userInput]); // BAD: Raw string interpolation DB::select("SELECT * FROM users WHERE email = '{$userInput}'"); // VULNERABLE! User::whereRaw("email = '{$userInput}'")->first(); // VULNERABLE! // BAD: whereRaw/orderByRaw with unescaped input User::orderByRaw($userInput); // VULNERABLE! User::groupByRaw($userInput); // VULNERABLE! // BAD: DB::statement with concatenation DB::statement("INSERT INTO users (email) VALUES ('{$userInput}')"); // VULNERABLE! ``` ### Attribute Casting ```php final class User extends Authenticatable { protected $casts = [ 'email_verified_at' => 'datetime', 'is_admin' => 'boolean', // Cast to boolean prevents string injection 'settings' => 'array', // Automatically json_encode/json_decode 'metadata' => 'encrypted:array', // Laravel 11+ encrypted casting 'password' => 'hashed', // Laravel 10+ auto-hashes on set ]; } ``` ### Model Security ```php final class User extends Authenticatable { // Hide sensitive attributes from JSON/API responses protected $hidden = [ 'password', 'remember_token', 'two_factor_secret', 'two_factor_recovery_codes', ]; // Append only safe computed attributes protected $appends = ['full_name']; // safe // NEVER append sensitive computed data } final class Post extends Model { // Global scope to filter soft deleted records use SoftDeletes; // Prevent N+1 by restricting lazy loading (optional strict mode) // AppServiceProvider::boot() // Model::preventLazyLoading(!app()->isProduction()); } ``` ## CSRF Protection ### Default Protection ```php // Laravel CSRF is enabled by default via VerifyCsrfToken middleware // app/Http/Kernel.php (protected $middlewareGroups['web']) // All POST/PUT/PATCH/DELETE forms must include @csrf
@csrf
``` ### Excluding Routes (Carefully) ```php // app/Http/Middleware/VerifyCsrfToken.php class VerifyCsrfToken extends Middleware { // Only exclude routes that have external CSRF protection (webhooks, etc.) protected $except = [ 'stripe/*', // Stripe webhooks use their own signature verification // Avoid blanket 'api/*' — stateful Sanctum routes need CSRF. // Exclude only specific stateless webhook/endpoint routes. ]; } ``` ### CSRF with JavaScript ```html ``` ## XSS Prevention ### Blade Templating Security ```blade {{-- SAFE: Auto-escaped by Blade --}} {{ $userInput }} {{-- DANGEROUS: Raw output — NEVER use with user input --}} {!! $userInput !!} {{-- SAFE: Only use {!! !!} with trusted content you control --}} {!! $trustedHtmlFromYourServer !!} {{-- GOOD: Use specific escaping directives --}} @js($data) {{-- JSON encode for JavaScript --}} @json($data) {{-- JSON encode in templates --}} {{-- BAD: Direct user input in raw HTML --}}
{!! $user->bio !!}
{{-- VULNERABLE if user provides bio --}} ``` ### Safe HTML Handling ```php // When you must allow some HTML, use a whitelist approach use HTMLPurifier; // Requires: composer require ezyang/htmlpurifier public function sanitizeHtml(string $dirty): string { $config = \HTMLPurifier_Config::createDefault(); $config->set('HTML.Allowed', 'p,b,i,a[href],ul,ol,li,br'); $config->set('URI.AllowedSchemes', ['http', 'https', 'mailto']); $purifier = new \HTMLPurifier($config); return $purifier->purify($dirty); } // In blade:
{!! $sanitizedContent !!}
{{-- Safe after purification --}} ``` ### JavaScript Context Escaping ```blade {{-- SAFE: Blade @js escapes for JavaScript context --}} {{-- DANGEROUS: Manual JSON in JS context --}} ``` ### HTTP Headers for XSS Protection ```php // App\Http\Middleware\SecurityHeaders.php class SecurityHeaders { public function handle(Request $request, Closure $next): mixed { $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( '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'" ); return $response; } } // Register in kernel protected $middleware = [ \App\Http\Middleware\SecurityHeaders::class, ]; ``` ## Input Validation ### Form Request Validation ```php final class StorePostRequest extends FormRequest { public function authorize(): bool { return $this->user()?->can('create', Post::class) ?? false; } public function rules(): array { return [ 'title' => ['required', 'string', 'max:255', 'sanitize_html'], 'content' => ['required', 'string', 'max:10000'], 'image' => [ 'required', 'image', 'mimes:jpg,jpeg,png,gif,webp', // Whitelist specific types 'max:2048', // 2MB max ], 'tags' => ['array'], 'tags.*' => ['integer', 'exists:tags,id'], ]; } public function messages(): array { return [ 'title.max' => 'Post title must not exceed 255 characters.', 'image.max' => 'Image must be under 2MB.', ]; } // Sanitize input after validation public function validated($key = null, $default = null): mixed { $validated = parent::validated(); $validated['title'] = strip_tags($validated['title']); return $key ? ($validated[$key] ?? $default) : $validated; } } ``` ### Custom Validation Rules ```php // app/Rules/StrongPassword.php class StrongPassword implements Rule { public function passes($attribute, $value): bool { return preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#^()_\-+=])[A-Za-z\d@$!%*?&#^()_\-+=]{12,}$/', $value); } public function message(): string { return 'The :attribute must be at least 12 characters with uppercase, lowercase, number, and symbol.'; } } // app/Rules/NotBlacklistedDomain.php class NotBlacklistedDomain implements Rule { private array $blacklisted = ['mailinator.com', 'guerrillamail.com']; public function passes($attribute, $value): bool { $domain = substr(strrchr($value, '@'), 1); return !in_array(strtolower($domain), $this->blacklisted); } public function message(): string { return 'Email from disposable domains is not allowed.'; } } ``` ## API Security ### Rate Limiting ```php // App/Providers/RouteServiceProvider protected function configureRateLimiting(): void { RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); }); RateLimiter::for('auth', function (Request $request) { return Limit::perMinute(5)->by($request->ip()) ->response(function () { return response()->json([ 'message' => 'Too many login attempts. Try again in 1 minute.', ], 429); }); }); RateLimiter::for('uploads', function (Request $request) { return Limit::perHour(10)->by($request->user()?->id ?? $request->ip()) ->response(function () { return response()->json([ 'message' => 'Upload limit reached. Try again later.', ], 429); }); }); } // Route usage Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () { Route::apiResource('posts', PostController::class); }); Route::post('/login', [AuthController::class, 'login']) ->middleware('throttle:auth'); ``` ### API Authentication — Sanctum vs Passport ```php // Sanctum (recommended for most apps — simple, first-party, SPA) // config/sanctum.php 'expiration' => 60 * 24, // Tokens expire after 24 hours 'model' => User::class, // Issuing scoped tokens $token = $user->createToken('client-name', [ 'posts:read', 'posts:write', ])->plainTextToken; // Middleware scoping Route::middleware('auth:sanctum')->group(function () { Route::get('/posts', [PostController::class, 'index']) ->middleware('abilities:posts:read'); Route::post('/posts', [PostController::class, 'store']) ->middleware('abilities:posts:write'); }); // Passport (OAuth2 — for third-party clients or complex auth flows) // Install: composer require laravel/passport Passport::tokensExpireIn(now()->addDays(15)); Passport::refreshTokensExpireIn(now()->addDays(30)); Passport::personalAccessTokensExpireIn(now()->addMonths(6)); ``` ### CORS Configuration ```php // config/cors.php return [ 'paths' => ['api/*', 'sanctum/csrf-cookie'], 'allowed_methods' => ['*'], 'allowed_origins' => explode(',', env('CORS_ALLOWED_ORIGINS', '')), // Whitelist specific origins 'allowed_origins_patterns' => [], 'allowed_headers' => ['*'], 'exposed_headers' => ['X-Total-Count', 'X-Pagination-Page'], 'max_age' => 0, 'supports_credentials' => true, // Required for Sanctum SPA auth ]; // NEVER: Allow all origins in production unless absolutely necessary // 'allowed_origins' => ['*'], // Only for truly public APIs ``` ## File Upload Security ### Validation ```php public function rules(): array { return [ 'document' => [ 'required', 'file', 'mimes:pdf,doc,docx,xls,xlsx', // Whitelist specific MIME types 'max:10240', // 10MB 'extensions:pdf,doc,docx,xls,xlsx', // Verify extension matches MIME ], 'avatar' => [ 'nullable', 'image', // Ensures it's a valid image 'mimes:jpg,jpeg,png,webp', 'max:2048', 'dimensions:min_width=100,min_height=100,max_width=2000,max_height=2000', ], ]; } ``` ### Secure Storage ```php // Store files outside public directory $path = $request->file('document')->store('documents', 'local'); // Never use 'public' disk for sensitive documents // Use signed URLs for temporary file access use Illuminate\Support\Facades\Storage; public function download(Request $request, string $path) { // Generate temporary signed URL (expires in 15 minutes) $url = Storage::temporaryUrl($path, now()->addMinutes(15)); // Validate user has permission $this->authorize('download', $path); return redirect($url); } // Storage configuration for cloud with encryption // config/filesystems.php 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION'), 'bucket' => env('AWS_BUCKET'), 'url' => env('AWS_URL'), 'endpoint' => env('AWS_ENDPOINT'), 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), 'throw' => false, 'server_side_encryption' => 'AES256', // Encrypt at rest ], ``` ## Dependencies and Secrets ### Composer Security ```bash # Always audit dependencies in CI composer audit # Pin major versions in composer.json "laravel/framework": "^11.0", "spatie/laravel-permission": "^6.0" # Check for abandoned packages composer why-not # Keep lock file in version control (it pins exact versions) # Run `composer update` deliberately, never in CI/CD ``` ### Secret Management ```bash # .env file (NEVER commit) # .gitignore includes .env by default APP_KEY=base64:abc123... DB_PASSWORD=secure_password STRIPE_KEY=sk_live_... SANCTUM_TOKEN_PREFIX=myapp_ # For production: Use a secret manager # Deploy with: env $(aws secretsmanager get-secret-value --secret-id prod/db | jq ...) php artisan serve # Validate secrets at boot (AppServiceProvider::boot) $secrets = ['services.stripe.key', 'services.stripe.webhook_secret']; foreach ($secrets as $key) { if (empty(config($key))) { Log::critical("Missing secret: {$key}"); } } ``` ## Queue Security ```php // Define a named rate limiter (typically in AppServiceProvider::boot()) RateLimiter::for('payments', fn () => Limit::perMinute(5)); ``` ```php // Encrypt sensitive job data by implementing the interface final class ProcessPaymentJob implements ShouldQueue, ShouldBeEncrypted { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public function __construct( private readonly string $paymentIntentId, // Public IDs are fine private readonly string $cardFingerprint, // Encrypted via ShouldBeEncrypted ) {} public function handle(): void { // Process payment } // Limit retries and delay between attempts public function retryUntil(): Carbon { return now()->addMinutes(5); } // Rate limit how many jobs of this type can run public function middleware(): array { return [ new RateLimited('payments'), ]; } } ``` ## Logging Security Events ```php // config/logging.php 'channels' => [ 'security' => [ 'driver' => 'single', 'path' => storage_path('logs/security.log'), 'level' => 'warning', ], ], // Audit log helper final class SecurityLogger { public static function log(string $event, array $context = []): void { Log::channel('security')->warning($event, array_merge([ 'user_id' => Auth::id(), 'ip' => request()->ip(), 'user_agent' => request()->userAgent(), 'url' => request()->fullUrl(), 'timestamp' => now()->toIso8601String(), ], $context)); } } // Usage SecurityLogger::log('failed_login_attempt', ['email' => $email]); SecurityLogger::log('password_change'); SecurityLogger::log('role_change', ['target_user' => $targetId, 'new_role' => 'admin']); SecurityLogger::log('suspicious_activity', ['reason' => 'multiple_attempts_from_different_ips']); ``` ## Quick Security Checklist | Check | Description | |-------|-------------| | `APP_DEBUG=false` | Never run with debug enabled in production | | `APP_KEY` set | Always run `php artisan key:generate` | | HTTPS enforced | Force HTTPS in production via middleware or proxy | | `$fillable` whitelisted | Never use `$guarded = []` | | CSRF active | `@csrf` on all state-changing forms | | Sanctum/Passport configured | API authentication with token abilities/scopes | | Rate limiting applied | Throttle API and auth endpoints | | Input validation | FormRequest with specific rules, never `$request->all()` | | File upload restrictions | Validate MIME types, size, dimensions | | `composer audit` in CI | Check dependencies for known vulnerabilities | | `password_hash` / `password_verify` | Use Laravel's built-in hashing (bcrypt/Argon2) | | Session regeneration on login | Call `$request->session()->regenerate()` | | Security headers middleware | CSP, X-Frame-Options, X-Content-Type-Options | | Logged security events | Audit log for auth failures, role changes, suspicious activity | | `.env` not committed | Verify `.gitignore` includes `.env` | ## Related Skills - `laravel-patterns` — Laravel architecture, routing, Eloquent, and API patterns - `backend-patterns` — General backend API and database patterns - `laravel-tdd` — Laravel testing with PHPUnit and Pest