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 anyX-Forwarded-Forheader 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_PATHSlist 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.
