# How to Secure a Laravel Application: 2026 Hardening Guide

> The complete production hardening guide for Laravel. Covers headers, sessions, environment config, rate limiting, authentication, and continuous monitoring.

**Author:** Matt King | **Published:** March 11, 2026 | **Category:** Security

---

## 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:**

```env
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:**

```bash
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:

```nginx
location ~ /\.env {
    deny all;
    return 404;
}
```

In Apache, add to `.htaccess`:

```apache
<Files .env>
    Order allow,deny
    Deny from all
</Files>
```

**Set proper file permissions:**

```bash
# 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:

```bash
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.

```php
// 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`:

```php
'bcrypt' => [
    'rounds' => 12, // Default is 12. Higher = slower but harder to brute force.
],
```

### Two-Factor Authentication

Add 2FA with Laravel Fortify:

```php
// config/fortify.php
'features' => [
    Features::twoFactorAuthentication([
        'confirm' => true,
        'confirmPassword' => true,
    ]),
],
```

For custom implementations, use the `pragmarx/google2fa-laravel` package:

```php
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`:

```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:

```php
// 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:

```php
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);
}
```

Laravel's `email:rfc,dns` rule checks syntax and DNS records, but it will not catch disposable or temporary email addresses. If your app has a free tier or trial, validating email quality at signup prevents fake accounts from entering your system in the first place. [Fidro](https://fidro.io) checks emails against 50,000+ known disposable domains and returns a risk score you can use in a custom validation rule.

For complex validation, use Form Request classes:

```php
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**:

```php
// Safe: parameterized automatically
$users = User::where('email', $request->email)->get();
$users = DB::table('users')->where('email', '=', $request->email)->get();
```

These are **dangerous**:

```php
// 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:

```php
// 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:

```php
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:

```blade
{{-- 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:

```php
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:

```blade
{{-- 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()`:

```blade
<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:

```blade
<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:

```javascript
// 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:

```blade
<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`:

```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.

```php
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:

1. **Validate file type by MIME, not extension.** Use the `mimes` or `mimetypes` validation rule.
2. **Set a maximum file size.** The `max` rule defines size in kilobytes.
3. **Store uploads outside the web root.** Use a private disk or store in `storage/app` (not `public`).
4. **Never use the original filename.** It can contain path traversal characters or executable extensions.
5. **Serve files through a controller** that checks authorization, not via direct URL access.

```php
// 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+):

```php
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:

```php
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:

```php
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:

```php
// 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:

```php
// 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:

```php
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:

```php
// 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:

```php
$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:

```bash
composer audit
```

Add it to your CI pipeline:

```yaml
# GitHub Actions example
- name: Security audit
  run: composer audit --format=json
```

This checks your installed packages against the [PHP Security Advisories Database](https://github.com/FriendsOfPHP/security-advisories).

### Keep Dependencies Updated

```bash
# 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`:

```yaml
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:

```bash
# 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`:

```php
public function boot(): void
{
    if ($this->app->environment('production')) {
        URL::forceScheme('https');
    }
}
```

Configure your `.env` for HTTPS:

```env
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`:

```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:

```bash
# 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:

```nginx
server_tokens off;
```

In Apache:

```apache
ServerTokens Prod
ServerSignature Off
```

In `php.ini`:

```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:

```php
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](https://stackshield.io) continuously monitors your Laravel application from the outside, checking for:

- Debug mode accidentally left on
- Missing or misconfigured security headers
- Exposed `.env` files 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.

[Run a free security scan on your Laravel app →](/free-scan) to check for exposed .env files, debug mode, missing security headers, and 30+ other vulnerabilities. Or explore the [full list of security checks](/tools/laravel-scanner) we run.

---

## 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.

