The Laravel Middleware Nobody Talks About (But Every Production App Should Have)

Rate limiting per user and per IP, suspicious request detection, force HTTPS, maintenance mode bypass tokens, content security policy headers, and trusted proxy configuration — the seven middleware layers that separate hobby projects from production-grade applications.


Every Laravel tutorial covers the basics: auth, verified, throttle. These ship with the framework, they’re documented, and every developer knows them.

The middleware that actually separates hobby projects from production applications is rarely discussed. It’s not glamorous. It doesn’t add features. But it’s the layer that handles the real world — scrapers, brute-force attacks, malformed requests, SSL stripping, proxy misconfiguration, and users who somehow always manage to find edge cases your development environment never showed you.

This post covers seven middleware layers that every production Laravel application should have. None of them require external packages. All of them have caused real production incidents when absent.


1. Trusted Proxy Configuration

Why This Is the First Problem to Solve

When your Laravel application sits behind a load balancer, Nginx proxy, or a CDN like Cloudflare, every request your application sees comes from the proxy’s IP address — not the user’s. This breaks:

  • request()->ip() returns the proxy IP instead of the user’s IP
  • Rate limiting by IP limits the proxy instead of individual users
  • SSL detection fails (request()->secure() returns false even on HTTPS)
  • Redirect generation uses the wrong scheme
  • Logging captures proxy IPs instead of real user IPs
// Without trusted proxy configuration:
$request->ip()     // → 10.0.0.1 (load balancer) instead of 203.0.113.45 (user)
$request->secure() // → false even when the user accessed via HTTPS

The Fix

Laravel ships with TrustProxies middleware. Configure it in bootstrap/app.php (Laravel 11+) or app/Http/Middleware/TrustProxies.php:

// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(
        at: env('TRUSTED_PROXIES', '*'),
        headers: Request::HEADER_X_FORWARDED_FOR |
                 Request::HEADER_X_FORWARDED_HOST |
                 Request::HEADER_X_FORWARDED_PORT |
                 Request::HEADER_X_FORWARDED_PROTO |
                 Request::HEADER_X_FORWARDED_AWS_ELB,
    );
})
# .env — set the trusted proxy IPs (or * for all proxies behind a CDN)
TRUSTED_PROXIES=192.168.1.1,192.168.1.2
# For Cloudflare or similar CDNs where the proxy IP changes:
TRUSTED_PROXIES=*

Security note: Setting TRUSTED_PROXIES=* trusts any X-Forwarded-For header from any source. This is appropriate when your server is only reachable through your proxy (e.g., your EC2 instances aren’t publicly accessible). If your server is directly accessible AND behind a proxy, specify exact proxy IPs instead of *.


2. Force HTTPS in Production

Sending redirects or emails with HTTP URLs in a production application that serves HTTPS is a common source of subtle bugs. The ForceHttps middleware ensures every request is HTTPS and that Laravel generates HTTPS URLs throughout the application.

// app/Http/Middleware/ForceHttps.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class ForceHttps
{
    public function handle(Request $request, Closure $next): Response
    {
        if (!app()->isLocal() && !$request->isSecure()) {
            return redirect()->secure($request->getRequestUri(), 301);
        }

        // Tell Laravel to generate HTTPS URLs even when the request comes
        // to the app server over HTTP (because SSL is terminated at the proxy)
        if (!app()->isLocal()) {
            $request->server->set('HTTPS', 'on');
            URL::forceScheme('https');
        }

        return $next($request);
    }
}
// bootstrap/app.php — apply globally
->withMiddleware(function (Middleware $middleware) {
    $middleware->prepend(ForceHttps::class);
})

Alternatively, in Laravel 11+ the cleaner approach is via AppServiceProvider:

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    if (!app()->isLocal()) {
        URL::forceScheme('https');
    }
}

3. Advanced Rate Limiting

Laravel’s built-in throttle middleware handles basic rate limiting. For production applications, you need rate limiting that’s:

  • Per user (authenticated) and per IP (unauthenticated) — separately
  • Different limits for different endpoint types (API vs web vs auth)
  • Aware of trusted proxies (so the right IP is used)
  • Properly communicating limits to clients via headers
// app/Providers/RouteServiceProvider.php (Laravel 10)
// OR bootstrap/app.php withRouting() (Laravel 11)

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

protected function configureRateLimiting(): void
{
    // API rate limiting — higher for authenticated users
    RateLimiter::for('api', function (Request $request) {
        return $request->user()
            ? Limit::perMinute(120)->by($request->user()->id)
            : Limit::perMinute(20)->by($request->ip());
    });

    // Auth endpoints — strict IP-based limiting to prevent brute force
    RateLimiter::for('auth', function (Request $request) {
        return [
            Limit::perMinute(5)->by($request->ip()),
            Limit::perMinute(3)->by($request->input('email') . '|' . $request->ip()),
        ];
    });

    // Expensive operations — per user, per hour
    RateLimiter::for('expensive', function (Request $request) {
        return $request->user()
            ? Limit::perHour(10)->by($request->user()->id)
            : Limit::perHour(3)->by($request->ip());
    });
}
// routes/api.php
Route::middleware(['throttle:api'])->group(function () {
    Route::get('/products', [ProductController::class, 'index']);
});

Route::middleware(['throttle:auth'])->group(function () {
    Route::post('/login', [AuthController::class, 'login']);
    Route::post('/forgot-password', [PasswordController::class, 'send']);
});

Route::middleware(['auth:sanctum', 'throttle:expensive'])->group(function () {
    Route::post('/reports/generate', [ReportController::class, 'generate']);
    Route::post('/export', [ExportController::class, 'export']);
});

Custom Rate Limit Response

The default 429 response isn’t informative. Override it to tell clients how long to wait:

// app/Exceptions/Handler.php
use Illuminate\Http\Exceptions\ThrottleRequestsException;

public function register(): void
{
    $this->renderable(function (ThrottleRequestsException $e, Request $request) {
        if ($request->expectsJson()) {
            return response()->json([
                'message'     => 'Too many requests. Please slow down.',
                'retry_after' => $e->getHeaders()['Retry-After'] ?? 60,
            ], 429, $e->getHeaders());
        }
    });
}

4. Suspicious Request Detection

Automated scanners, bots, and basic attackers send requests that no legitimate user would ever send. A middleware that detects and rejects these patterns at the application layer reduces noise in logs, protects endpoints, and catches automated attacks before they reach application logic.

// app/Http/Middleware/RejectSuspiciousRequests.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class RejectSuspiciousRequests
{
    // Path patterns that no legitimate user navigates to
    private const SUSPICIOUS_PATHS = [
        '/wp-admin',
        '/wp-login.php',
        '/wp-content',
        '/.env',
        '/.git',
        '/config.php',
        '/phpinfo.php',
        '/phpmyadmin',
        '/admin.php',
        '/xmlrpc.php',
        '/.well-known/security.txt',
        '/vendor/',
        '/storage/logs',
        '/node_modules',
        '/.htaccess',
        '/server-status',
        '/actuator',              // Spring Boot scanner
        '/api/v1/auth/login',    // generic API scanner (not your route)
    ];

    // User agents associated with vulnerability scanners
    private const SUSPICIOUS_USER_AGENTS = [
        'sqlmap',
        'nikto',
        'nessus',
        'masscan',
        'zgrab',
        'nuclei',
        'acunetix',
        'burpsuite',
        'netsparker',
        'openvas',
        'python-requests/2.',  // often automated scanning
    ];

    public function handle(Request $request, Closure $next): Response
    {
        $path      = strtolower($request->path());
        $userAgent = strtolower($request->header('User-Agent', ''));

        // Check for WordPress and common CMS scanner paths
        foreach (self::SUSPICIOUS_PATHS as $pattern) {
            if (str_starts_with('/' . $path, $pattern)) {
                return $this->rejectRequest($request, 'suspicious_path');
            }
        }

        // Check for known scanner user agents
        foreach (self::SUSPICIOUS_USER_AGENTS as $agent) {
            if (str_contains($userAgent, $agent)) {
                return $this->rejectRequest($request, 'suspicious_agent');
            }
        }

        // Reject requests with path traversal attempts
        if (str_contains($request->getRequestUri(), '../') ||
            str_contains($request->getRequestUri(), '%2e%2e')) {
            return $this->rejectRequest($request, 'path_traversal');
        }

        // Reject abnormally large payloads (adjust limit for file upload routes)
        $maxPayload = 10 * 1024 * 1024; // 10MB
        if ($request->header('Content-Length') > $maxPayload) {
            return $this->rejectRequest($request, 'oversized_payload');
        }

        return $next($request);
    }

    private function rejectRequest(Request $request, string $reason): Response
    {
        // Log for visibility — don't log the full request body
        logger()->warning('Suspicious request rejected', [
            'reason'     => $reason,
            'ip'         => $request->ip(),
            'path'       => $request->path(),
            'user_agent' => $request->header('User-Agent'),
            'method'     => $request->method(),
        ]);

        // Return a generic 404 — don't confirm the path exists with a 403
        abort(404);
    }
}

Tune this for your application. The SUSPICIOUS_PATHS list should include paths that your application definitely doesn’t serve, not paths that might be legitimate. False positives on legitimate paths cause real user problems. Start conservative and expand the list based on your actual scanner traffic.


5. Security Headers (CSP, HSTS, and Friends)

HTTP security headers defend against XSS, clickjacking, MIME-type sniffing, and protocol downgrade attacks. Without them, browsers apply no additional defence against these attack vectors. With them, you get significant protection at zero performance cost.

// app/Http/Middleware/SecurityHeaders.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class SecurityHeaders
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        // Prevent MIME type sniffing
        $response->headers->set('X-Content-Type-Options', 'nosniff');

        // Prevent clickjacking — page can only be embedded by same origin
        $response->headers->set('X-Frame-Options', 'SAMEORIGIN');

        // Control referrer information sent to other sites
        $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');

        // Disable browser features your app doesn't use
        $response->headers->set('Permissions-Policy',
            'camera=(), microphone=(), geolocation=(), payment=()'
        );

        // HSTS — tell browsers to always use HTTPS for this domain
        // max-age=31536000 = 1 year; includeSubDomains covers all subdomains
        if (!app()->isLocal()) {
            $response->headers->set(
                'Strict-Transport-Security',
                'max-age=31536000; includeSubDomains; preload'
            );
        }

        // Content Security Policy — the most powerful and most complex
        // Start with report-only mode to avoid breaking existing functionality
        $csp = implode('; ', [
            "default-src 'self'",
            "script-src 'self' 'nonce-" . $this->getCspNonce() . "' 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' " . env('VITE_API_URL', ''),
            "frame-ancestors 'self'",
            "base-uri 'self'",
            "form-action 'self'",
        ]);

        // Use report-only first to catch violations without blocking
        // Change to Content-Security-Policy when you've verified it doesn't break anything
        $response->headers->set('Content-Security-Policy-Report-Only', $csp);

        return $response;
    }

    private function getCspNonce(): string
    {
        if (!session()->has('csp_nonce')) {
            session(['csp_nonce' => base64_encode(random_bytes(16))]);
        }
        return session('csp_nonce');
    }
}

Deploying CSP Safely

CSP is powerful but easy to misconfigure — a wrong CSP blocks legitimate scripts and breaks the application. The safe rollout process:

Step 1: Deploy with Content-Security-Policy-Report-Only
        → CSP violations are reported but not blocked
        → Add report-uri to collect violations:
          "report-uri /csp-report"

Step 2: Collect violations for 1-2 weeks
        → Identify inline scripts, third-party resources, etc.

Step 3: Refine the CSP to allow legitimate resources

Step 4: Switch to Content-Security-Policy (enforced)
        → Now violations are blocked, not just reported
// routes/web.php — CSP violation reporting endpoint
Route::post('/csp-report', function (Request $request) {
    $report = $request->getContent();
    Log::warning('CSP violation', ['report' => json_decode($report, true)]);
    return response()->noContent();
})->withoutMiddleware([VerifyCsrfToken::class]);

6. Maintenance Mode with Bypass Tokens

Laravel’s built-in php artisan down maintenance mode is excellent — but the built-in bypass mechanism using an --allow IP flag is awkward when your team is distributed or behind dynamic IPs.

A token-based bypass lets your team preview the application while it’s down:

// app/Http/Middleware/MaintenanceModeBypass.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance;
use Illuminate\Http\Request;

class MaintenanceModeBypass extends PreventRequestsDuringMaintenance
{
    // Paths exempt from maintenance mode — health check should always be accessible
    protected $except = [
        '/health',
        '/up',
        '/csp-report',
    ];

    public function handle(Request $request, Closure $next)
    {
        // Check for bypass token in cookie or query string
        $bypassToken  = config('app.maintenance_bypass_token');
        $requestToken = $request->cookie('maintenance_bypass') ??
                        $request->query('maintenance_token');

        if ($bypassToken && $requestToken === $bypassToken) {
            // Set a cookie so they don't need the token on every request
            $response = $next($request);
            $response->cookie(
                'maintenance_bypass',
                $bypassToken,
                60,          // 60 minutes
                '/',
                null,
                true,        // secure
                true         // httpOnly
            );
            return $response;
        }

        return parent::handle($request, $next);
    }
}
# .env
APP_MAINTENANCE_BYPASS_TOKEN=your-secret-bypass-token-here

# Put the app in maintenance mode
php artisan down --render="errors.503" --secret="your-secret-bypass-token-here"

# Your team accesses the app via:
# https://yourapp.com?maintenance_token=your-secret-bypass-token-here
# After that, the cookie keeps them in for 60 minutes
// bootstrap/app.php — replace the built-in maintenance middleware
->withMiddleware(function (Middleware $middleware) {
    $middleware->replace(
        \Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance::class,
        \App\Http\Middleware\MaintenanceModeBypass::class,
    );
})

7. Request Logging for Production Debugging

In production, you cannot dd(). You cannot Log::info() everywhere. What you can do is have a middleware that logs the right information about every request — enough to reconstruct what a user experienced without logging sensitive data.

// app/Http/Middleware/RequestLogger.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;

class RequestLogger
{
    // Fields to strip from logged payloads
    private const SENSITIVE_FIELDS = [
        'password', 'password_confirmation', 'current_password',
        'card_number', 'cvv', 'token', 'secret', 'api_key',
        'credit_card', 'ssn', 'national_id',
    ];

    public function handle(Request $request, Closure $next): Response
    {
        $startTime = microtime(true);

        $response = $next($request);

        $duration = round((microtime(true) - $startTime) * 1000, 2);

        // Only log slow requests, errors, or authenticated users' actions
        $shouldLog = $duration > 2000
            || $response->getStatusCode() >= 400
            || ($request->user() && $request->isMethod('POST'));

        if ($shouldLog) {
            Log::channel('requests')->info('Request', [
                'method'      => $request->method(),
                'path'        => $request->path(),
                'status'      => $response->getStatusCode(),
                'duration_ms' => $duration,
                'user_id'     => $request->user()?->id,
                'ip'          => $request->ip(),
                'user_agent'  => $request->header('User-Agent'),
                'payload'     => $this->sanitizePayload($request->all()),
            ]);
        }

        // Always add timing to response headers for monitoring
        $response->headers->set('X-Request-Duration', $duration . 'ms');

        return $response;
    }

    private function sanitizePayload(array $payload): array
    {
        foreach ($payload as $key => $value) {
            if (in_array(strtolower($key), self::SENSITIVE_FIELDS)) {
                $payload[$key] = '[REDACTED]';
            } elseif (is_array($value)) {
                $payload[$key] = $this->sanitizePayload($value);
            }
        }
        return $payload;
    }
}
// config/logging.php — dedicated channel for request logs
'channels' => [
    'requests' => [
        'driver' => 'daily',
        'path'   => storage_path('logs/requests.log'),
        'days'   => 7,
        'level'  => 'info',
    ],
]

Putting It All Together

Registering All Middleware

// bootstrap/app.php — complete middleware stack
->withMiddleware(function (Middleware $middleware) {

    // Global middleware — applies to every request
    $middleware->use([
        ForceHttps::class,
        SecurityHeaders::class,
        RejectSuspiciousRequests::class,
        RequestLogger::class,
    ]);

    // Replace built-in maintenance middleware with the bypass version
    $middleware->replace(
        \Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance::class,
        MaintenanceModeBypass::class,
    );

    // Trust proxy configuration
    $middleware->trustProxies(
        at: env('TRUSTED_PROXIES', ''),
        headers: Request::HEADER_X_FORWARDED_FOR |
                 Request::HEADER_X_FORWARDED_HOST |
                 Request::HEADER_X_FORWARDED_PORT |
                 Request::HEADER_X_FORWARDED_PROTO,
    );

    // Middleware aliases for use in routes
    $middleware->alias([
        'suspicious' => RejectSuspiciousRequests::class,
        'security'   => SecurityHeaders::class,
    ]);
})

Route-Level Application

// routes/api.php
Route::middleware([
    'throttle:api',
    'suspicious',
])->group(function () {
    // API routes
});

Route::middleware(['throttle:auth'])->group(function () {
    Route::post('/login',           [AuthController::class, 'login']);
    Route::post('/register',        [AuthController::class, 'register']);
    Route::post('/forgot-password', [PasswordController::class, 'send']);
    Route::post('/verify-otp',      [OtpController::class, 'verify']);
});

The Production Middleware Checklist

Deployment configuration:
✓ TrustProxies configured — request()->ip() returns real user IP
✓ TRUSTED_PROXIES env variable set correctly for your infrastructure
✓ ForceHttps middleware active in all non-local environments
✓ URL::forceScheme('https') in AppServiceProvider for non-local environments

Rate limiting:
✓ Named rate limiters defined for api, auth, and expensive operations
✓ Auth endpoints (login, register, forgot-password) have strict throttle
✓ Authenticated users get higher limits than anonymous
✓ Rate limit uses user ID for authenticated requests, IP for anonymous

Security headers:
✓ X-Content-Type-Options: nosniff
✓ X-Frame-Options: SAMEORIGIN or DENY
✓ Referrer-Policy set
✓ Permissions-Policy disables unused browser features
✓ HSTS header present in non-local environments
✓ CSP deployed in report-only mode initially, violations reviewed

Suspicious request detection:
✓ WordPress scanner paths blocked
✓ Vulnerability scanner user agents blocked
✓ Path traversal attempts blocked
✓ Suspicious requests logged with IP for monitoring

Maintenance mode:
✓ Bypass token configured for team access during maintenance
✓ Health check endpoint exempted from maintenance mode
✓ Custom 503 page configured

Request logging:
✓ Slow requests (> 2000ms) logged
✓ 4xx and 5xx responses logged
✓ Sensitive fields (password, token, card_number) redacted from logs
✓ X-Request-Duration header added for monitoring visibility

Final Thoughts

The gap between a working Laravel application and a production-grade one is rarely the features. It’s the operational layer — the middleware that handles the real world’s attacks, its proxies, its bots, its protocol requirements, and its debugging needs.

None of the middleware in this post is complex. None of it requires external packages. The suspicious request detector, the security headers, the bypass token — these are all under 50 lines each.

What they provide is disproportionate to their size. Trusted proxy configuration makes every IP-based feature in the application work correctly. Security headers add browser-level XSS and clickjacking protection at zero performance cost. Rate limiting protects auth endpoints from brute force. The maintenance bypass token lets your team deploy with confidence.

Add these to a new application before you have users. Retrofit them to an existing application before the first security audit. Either way, they’re an afternoon of work that prevents incidents that would otherwise take days to diagnose and remediate.

The middleware nobody talks about is doing the work you never want to notice. That’s exactly the point.

Leave a Reply

Your email address will not be published. Required fields are marked *