Laravel Security
Security

Securing Your Laravel Application

Mayur Dabhi
Mayur Dabhi
March 17, 2026
28 min read

Most Laravel security holes I come across aren't exotic. A {!! !!} that renders a user's bio, an orderBy($request->sort) nobody whitelisted, a controller that loads a post by ID without checking who owns it, or APP_DEBUG=true left on in production so a stack trace prints the database password. The framework had a safe default for every one of these, and someone stepped around it.

This post goes through the defaults Laravel gives you and where people usually break them: CSRF, XSS, SQL injection, authentication, authorization with policies, validation, rate limiting, encryption, security headers, file uploads, and a checklist for Secure production deploys.

What You'll Learn
  • Laravel's built-in security features and how they work
  • Protecting against CSRF, XSS, and SQL injection attacks
  • Solid authentication and authorization with policies
  • 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

The OWASP Top 10 is the usual reference list for web app risks. Laravel ships with a default defense for most of the common ones, as long as you don't switch it off. Here's how the attacks map to what the framework does about them:

Common Attack Vectors & Laravel Defenses ⚠️ Attack Vectors SQL Injection Malicious SQL in user inputs Cross-Site Scripting (XSS) Injected malicious scripts CSRF Attacks Forged authenticated requests Mass Assignment Unauthorized model attribute changes 🛡️ Laravel Defenses Eloquent ORM & Query Builder Parameterized queries by default Blade Auto-Escaping {{ }} escapes output automatically CSRF Tokens @csrf directive & VerifyCsrfToken $fillable & $guarded Explicit attribute whitelisting

Common attacks and the Laravel feature that blocks each one by default

CSRF Protection: Preventing Cross-Site Request Forgery

With Cross-Site Request Forgery (CSRF), another site gets a logged-in user's browser to submit a request to your app, and the browser happily attaches the session cookie. Laravel blocks this by issuing a token per session and rejecting state-changing requests that don't send it back.

How CSRF Protection Works

🦹
Attacker Creates malicious form on evil-site.com targeting your-app.com
👤
Victim (Logged In) Visits evil-site.com while authenticated on your-app.com
🛡️
Laravel CSRF Middleware Rejects request - no valid CSRF token present!

Implementing CSRF Protection

CSRF checks are on by default for POST, PUT, PATCH, and DELETE requests in the web middleware group. Your job is mostly to remember @csrf in every form:

Blade Template - Form with CSRF
<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:

JavaScript - AJAX with CSRF
// 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'
});
Pro Tip

Add the CSRF meta tag to your layout's <head> section: <meta name="csrf-token" content="{{ csrf_token() }}">. Then your JavaScript can read the token without you passing it around by hand.

When the token is missing or stale, Laravel throws a TokenMismatchException and the user sees a 419 Page Expired page. If you start getting 419s right after a deploy, the token usually isn't the problem. Check whether the session is being lost instead: a changed SESSION_DOMAIN, SESSION_SECURE_COOKIE=true on a site still served over plain HTTP, or file sessions on several servers behind a load balancer. Don't "fix" it by adding routes to the CSRF exception list.

XSS Prevention: Escaping Output

With Cross-Site Scripting (XSS), an attacker gets their JavaScript to run in other users' browsers, usually by saving it somewhere your app later renders. Blade's {{ }} escapes output for you. The trouble starts when someone reaches for {!! !!} because the HTML "looked wrong".

Auto-Escaped Output

<!-- SAFE: Automatically escapes HTML entities -->
<p>Welcome, {{ $user->name }}</p>

<!-- Input: <script>alert('XSS')</script> -->
<!-- Output: &lt;script&gt;alert('XSS')&lt;/script&gt; -->

<!-- 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

Eloquent and the Query Builder send values through PDO parameter binding, so normal queries can't be turned into SQL injection. The risk comes back when you build SQL strings yourself or pass user input as a column name, which bindings can't protect.

Safe Practices

Eloquent ORM, Query Builder with bindings, parameterized raw queries

Dangerous Practices

Raw queries with concatenated user input, unvalidated orderBy columns

Safe Database Queries
// ✅ 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 Patterns to Avoid
// ❌ 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();
Critical Warning

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

You can start from Breeze, Jetstream, Fortify, or roll your own on top of the auth guards. The starter kits get the basics right. The practices below apply whichever one you pick, and matter most if you wrote the login flow yourself.

Password Security

Secure Password Handling
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:

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

Fortify has 2FA built in. If you're not using it, pragmarx/google2fa-laravel handles TOTP codes for you:

Two-Factor Authentication Setup
// 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

Logging someone in only tells you who they are. Whether they may edit post #42 is a separate question, and forgetting to ask it is how one user ends up editing another user's data by changing an ID in the URL. Laravel gives you Gates for one-off checks and Policies for rules tied to a model.

Laravel Authorization Flow 👤 User Request Gate / Policy authorize() $this->authorize() @can directive ✓ Allowed Continue to action ✗ Denied 403 Forbidden Response Error Page
Defining a Policy
// 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
    }
}
Using Authorization in Controllers
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

Treat every request field as hostile until a rule says otherwise. I put rules in Form Request classes so controllers stay short and the rules are easy to find in review:

Comprehensive Validation Example
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

Without limits, a login form will take as many password guesses as a script can send. You define named limiters with the RateLimiter facade and attach them to routes with the throttle middleware.

app/Providers/RouteServiceProvider.php
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);
            });
    });
}
Applying Rate Limits to Routes
// 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');
});

One gotcha: if the app sits behind a load balancer or Cloudflare and you haven't configured trusted proxies, $request->ip() returns the proxy's address. Every visitor then shares one bucket, and your login limiter locks out everyone at once.

Encryption & Secure Data Storage

Laravel's encrypter uses AES-256-CBC by default and signs the payload, so tampered values fail to decrypt. For things like national ID numbers or third-party API keys, encrypt the column so a leaked database dump isn't readable on its own.

Encryption in Practice
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
Important: Protect Your APP_KEY

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. Be careful with rotation: running php artisan key:generate on a live app logs everyone out and makes existing encrypted columns throw DecryptException: The MAC is invalid. On Laravel 11 and later, put the old key in APP_PREVIOUS_KEYS so old data still decrypts, then re-encrypt it in the background.

Security Headers

Headers tell the browser what it may and may not do with your pages. You can set them in Nginx or in a small middleware like this one:

app/Http/Middleware/SecurityHeaders.php
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;
    }
}

Two notes on that middleware. X-XSS-Protection is legacy: current browsers ignore it, so it's harmless but doesn't protect anything. And 'unsafe-inline' in script-src lets most injected inline scripts run, which defeats a big part of CSP. Drop it if you can, or move to nonces once you know which inline scripts you actually have. Roll out a strict CSP with Content-Security-Policy-Report-Only first, or you'll find the broken pages from user reports.

Secure File Uploads

An upload endpoint lets strangers put files on your server. Check the type, give the file your own name, and keep it out of the public web root unless it really needs to be there.

Secure File Upload Implementation
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

I run through this list before a launch and again whenever a project changes hands:

Environment Configuration APP_DEBUG=false in production, APP_ENV=production
HTTPS Enforced Force HTTPS in AppServiceProvider or web server config
CSRF Protection Active All forms include @csrf, VerifyCsrfToken middleware enabled
SQL Injection Prevention Using Eloquent/Query Builder, no raw string concatenation
XSS Prevention Using {{ }} for output, sanitizing user HTML
Password Security Using Hash::make(), strong password validation rules
Rate Limiting Login, API, and sensitive endpoints throttled
Authorization Checks Policies/Gates for all resource access
Mass Assignment Protection $fillable or $guarded defined on all models
Secure Session Config Secure cookies, HTTP-only, SameSite attribute set
Security Headers CSP, X-Frame-Options, HSTS configured
Dependencies Updated Run composer audit regularly, keep packages current

Production Hardening

.env (Production)
# 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
Production Deployment Commands
#!/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!"

Mistakes I See Most Often

Where Laravel apps usually leak

  • Missing ownership checks: the route is behind auth, but nothing stops user A from loading /invoices/123 that belongs to user B. Authentication isn't authorization.
  • $guarded = [] with $request->all(): convenient until someone posts is_admin=1. Pass $request->validated() instead.
  • Debug mode in production: with APP_DEBUG=true, the error page can show environment variables to anyone who triggers an exception.
  • Over-broad CSRF exceptions: excluding api/* or a whole route group to fix a 419 instead of finding why the session dropped.
  • Copying security headers blindly: a strict CSP pasted from a blog post breaks your own scripts, so someone loosens it until it does nothing.

Not everything here pays off on every project. Encrypted casts make columns unsearchable and unsortable in SQL, so I only use them on fields you'd never filter by. Strict per-IP rate limits can hurt offices or mobile carriers where many people share one address. Breached-password checks with uncompromised() call an external API during registration, which adds latency and a dependency you may not want on an internal tool. Go through the checklist, but weigh each item against what the app actually stores and who uses it.

And run composer audit in CI, not just by hand. Plenty of real Laravel vulnerabilities come from outdated packages rather than your own code, and it costs nothing to catch them on every build.

Laravel Security Best Practices CSRF XSS Authentication Authorization
Mayur Dabhi

Mayur Dabhi

Full Stack Developer specializing in Laravel, React, and modern web technologies. Passionate about building secure, scalable applications.