Bekzod Erkinov
Listen to Article
Loading...Table of contents · 9 sections
Security Hardening a Laravel App: OWASP Top 10 in Practice
In January 2021, CVE-2021-3129 showed how much damage one config value can do. On any Laravel app running facade/ignition older than 2.5.2 with APP_DEBUG=true, an unauthenticated attacker could POST to /_ignition/execute-solution, abuse the MakeViewVariableOptionalSolution class to write a PHAR payload into the log file, and get remote code execution. Nothing in the application code was wrong. The routes were fine, the validation was fine, the policies were fine. The server still belonged to someone else because a debug flag meant for a laptop had been left on in production.
That is what most real Laravel compromises look like. The framework escapes Blade output, parameterizes Eloquent queries, checks CSRF tokens and hashes passwords with bcrypt by default. Breaches come from the places where developers step outside those defaults: a {!! !!} added for a "trusted" field, an orderBy($request->sort), a $guarded = [] added to quiet a MassAssignmentException, a controller that loads Invoice::find($id) without checking who owns the invoice.
This tutorial goes through the OWASP Top 10 (2021) one category at a time and maps each one to concrete Laravel code. I use the 2021 numbering because that is still what most audit checklists and compliance documents cite. The examples target Laravel 11 and 12, with the slimmer bootstrap/app.php structure, but nearly everything carries over to Laravel 10 with small changes to where middleware gets registered.
A01: Broken Access Control — The IDOR You Already Have
Broken access control sits at number one because it is the bug frameworks can't fix for you. Laravel can't know that invoice 4812 belongs to tenant 17. Here is the classic Insecure Direct Object Reference:
// routes/web.php
Route::get('/invoices/{invoice}', [InvoiceController::class, 'show'])
->middleware('auth');
// app/Http/Controllers/InvoiceController.php
public function show(Invoice $invoice)
{
return view('invoices.show', compact('invoice'));
}
The auth middleware only proves the visitor is logged in. Any authenticated user can walk /invoices/1 through /invoices/99999 and read every customer's billing data. Route model binding makes the bug easier to write, because nothing in the controller even looks like a lookup.
Policies, not inline checks
Generate a policy and centralize the rule there:
php artisan make:policy InvoicePolicy --model=Invoice
namespace App\Policies;
use App\Models\Invoice;
use App\Models\User;
class InvoicePolicy
{
public function view(User $user, Invoice $invoice): bool
{
return $user->team_id === $invoice->team_id;
}
public function update(User $user, Invoice $invoice): bool
{
return $user->team_id === $invoice->team_id
&& $user->hasRole('billing-admin');
}
}
Laravel discovers the policy automatically if the model and policy follow the App\Models\Invoice → App\Policies\InvoicePolicy naming convention. Now enforce it. One detail trips up people upgrading to Laravel 11: the default base Controller class no longer uses the AuthorizesRequests trait, so $this->authorize() fails with Call to undefined method App\Http\Controllers\InvoiceController::authorize(). Either add the trait back or use the Gate facade, which gained an authorize method for this purpose:
use Illuminate\Support\Facades\Gate;
public function show(Invoice $invoice)
{
Gate::authorize('view', $invoice);
return view('invoices.show', compact('invoice'));
}
A denial throws AuthorizationException and renders as a 403. For routes, the can middleware performs the same check before the controller runs:
Route::put('/invoices/{invoice}', [InvoiceController::class, 'update'])
->middleware(['auth', 'can:update,invoice']);
Scope queries, not just single records
Policies protect single-record endpoints. Index pages and search endpoints need scoped queries, since nobody calls a policy on every row of a paginated list. A global scope keeps tenant data out of queries that forget to filter:
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class TeamScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if ($user = auth()->user()) {
$builder->where($model->qualifyColumn('team_id'), $user->team_id);
}
}
}
use App\Models\Scopes\TeamScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
#[ScopedBy([TeamScope::class])]
class Invoice extends Model
{
// ...
}
Because route model binding runs through the query builder, /invoices/4812 now returns a 404 for users on other teams. That is better than a 403, which confirms the record exists. For nested resources, scopeBindings() makes sure that in /teams/{team}/invoices/{invoice} the invoice really belongs to that team instead of resolving the two independently:
Route::scopeBindings()->group(function () {
Route::get('/teams/{team}/invoices/{invoice}', [TeamInvoiceController::class, 'show']);
});
The full policy and gate API is documented in the Laravel authorization guide.
A02: Cryptographic Failures — Keys, Casts and Hashes
Treat APP_KEY like a root password
APP_KEY encrypts cookies and session payloads, signs URLs, and backs the Crypt facade. In Laravel before 5.5.41 and 5.6.30, a leaked key was a direct path to RCE (CVE-2018-15133), because encrypted cookies were passed to unserialize() after decryption. Current versions no longer serialize cookie values by default, but a leaked key still lets an attacker forge signed URLs and decrypt anything you protected with Crypt.
Rotating the key used to log out every user at once. Laravel 11 added graceful rotation: put the old keys in APP_PREVIOUS_KEYS and the encrypter will try them when decryption with the current key fails.
# .env after rotation
APP_KEY=base64:Nq3x...new...
APP_PREVIOUS_KEYS=base64:7hYb...old...
Once the session lifetime has passed and you have re-encrypted any stored ciphertext, remove the old key.
Encrypt sensitive columns at rest
Database dumps leak. Backups land in misconfigured S3 buckets. For columns like API tokens for third-party services, national ID numbers, or OAuth refresh tokens, the encrypted cast encrypts with AES-256-CBC plus a MAC:
protected function casts(): array
{
return [
'stripe_secret' => 'encrypted',
'medical_notes' => 'encrypted:array',
'tax_id' => 'encrypted',
];
}
Keep two constraints in mind. Encrypted columns can't be searched with WHERE, because the same plaintext encrypts to different ciphertext each time. The ciphertext is also much longer than the input, so use TEXT, not VARCHAR(255). If you need to look a record up by an encrypted value, store a separate keyed hash (hash_hmac('sha256', $value, $pepper)) in an indexed column.
Password hashing defaults are good; don't undo them
Laravel uses bcrypt with a cost factor from BCRYPT_ROUNDS. The default is 12 in Laravel 11 and later, up from 10. On typical server hardware, cost 12 takes roughly 200–300 ms per hash, which is the point. Make sure your test environment lowers it (BCRYPT_ROUNDS=4 in phpunit.xml) and production doesn't. Also check that nobody "optimized" login by calling md5() or sha1() anywhere in the codebase:
grep -rnE "\b(md5|sha1)\s*\(" app/ --include=*.php
Each hit isn't automatically a vulnerability, since checksums and cache keys are fine uses. Each hit next to the word "password" or "token" is.
A03: Injection — Where Eloquent Stops Protecting You
Eloquent and the query builder bind values through PDO prepared statements. These are safe:
User::where('email', $request->email)->first();
DB::select('select * from users where email = ?', [$request->email]);
Injection comes back through the raw methods and through identifiers, meaning column names, sort directions and table names, which can't be bound as parameters.
Raw expressions
// Vulnerable: string interpolation inside a raw clause
$orders = Order::whereRaw("status = '{$request->status}'")->get();
// Safe: bindings as the second argument
$orders = Order::whereRaw('status = ?', [$request->status])->get();
The same applies to selectRaw, havingRaw, orderByRaw and DB::raw(). A useful CI check is to flag any Raw( call whose first argument contains $ or {:
grep -rnE "(whereRaw|selectRaw|orderByRaw|havingRaw|DB::raw)\(\s*\"[^\"]*\\$" app/
Sort columns and directions
This pattern shows up in almost every data table endpoint:
$users = User::orderBy($request->input('sort', 'id'), $request->input('dir', 'asc'))->paginate();
Laravel's grammar wraps the column in identifier quotes and validates the direction against asc/desc, throwing InvalidArgumentException: Order direction must be "asc" or "desc". otherwise. Even so, letting users pick arbitrary columns lets them sort by password or remember_token and infer values through timing or ordering side channels. Use an allow-list:
$request->validate([
'sort' => ['nullable', Rule::in(['id', 'name', 'created_at'])],
'dir' => ['nullable', Rule::in(['asc', 'desc'])],
]);
$users = User::orderBy($request->input('sort', 'id'), $request->input('dir', 'asc'))
->paginate();
Mass assignment is injection too
Mass assignment is OWASP's "injection of unexpected fields" and belongs here. Consider:
$user->update($request->all());
With $guarded = [] on the model, a request body containing "is_admin": true gives the attacker admin rights. The fix has two parts. First, only ever pass validated data, $request->validated() and not $request->all(). Second, make violations loud during development:
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::shouldBeStrict(! $this->app->isProduction());
}
shouldBeStrict() turns on preventLazyLoading, preventAccessingMissingAttributes and preventSilentlyDiscardingAttributes. With the last one enabled, filling a non-fillable attribute throws Illuminate\Database\Eloquent\MassAssignmentException with a message like Add fillable property [is_admin] to allow mass assignment on [App\Models\User]. Without it, the attribute is silently dropped and the bug in the calling code goes unnoticed.
Command and template injection
Process::run() (Laravel 10+) accepts an array of arguments, and that form never goes through a shell:
use Illuminate\Support\Facades\Process;
// Vulnerable: shell string with user input
Process::run("convert {$request->file} out.png");
// Safe: argv array, no shell interpretation
Process::run(['convert', $path, storage_path('app/out.png')]);
XSS in Blade
{{ $value }} runs the value through htmlspecialchars with ENT_QUOTES, and that is safe in HTML body and quoted attribute contexts. {!! $value !!} outputs raw HTML. Every {!! in your views should come with a comment explaining why the content is safe, and user-generated rich text should go through an HTML sanitizer such as mews/purifier or symfony/html-sanitizer before it is stored or rendered.
Escaping doesn't help in JavaScript and URL contexts. <a href="{{ $user->website }}"> will happily render javascript:alert(document.cookie). Validate URLs with the url rule restricted to schemes, 'website' => ['url:http,https'], and pass data to scripts with @js() or Js::from(), which JSON-encodes with the hex flags that make the output safe inside a <script> block:
<script>
window.App = {{ Js::from(['user' => $user->only('id', 'name')]) }};
</script>
A04 and A07: Insecure Design and Authentication Failures
OWASP split these into separate categories, but in a Laravel app they mostly come down to the same few mechanisms: rate limits, session lifecycle, and password reset and recovery flows.
Rate limiting that actually limits
Laravel's starter kits throttle login attempts, but custom API auth endpoints, OTP verification, and "resend code" buttons often have no limit at all. A six-digit OTP has 1,000,000 combinations. Without a limit, an attacker sending 100 requests per second covers the space in under three hours. With five attempts per minute per user, the same attack takes about 139 days, and the code expires long before then.
Define named limiters in AppServiceProvider::boot():
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('login', function (Request $request) {
$email = strtolower((string) $request->input('email'));
return [
Limit::perMinute(5)->by($email.'|'.$request->ip()),
Limit::perMinute(30)->by($request->ip()),
];
});
RateLimiter::for('otp', function (Request $request) {
return Limit::perMinutes(10, 5)->by($request->user()?->id ?: $request->ip());
});
Returning an array of limits applies all of them. The first stops brute force against a single account. The second stops credential stuffing from a single IP that rotates through many emails. Apply them with the throttle middleware:
Route::post('/login', [LoginController::class, 'store'])->middleware('throttle:login');
Route::post('/2fa/verify', [TwoFactorController::class, 'verify'])
->middleware(['auth', 'throttle:otp']);
A blocked request gets a 429 Too Many Requests with Retry-After and X-RateLimit-Remaining headers. Rate limiters store their counters in the default cache store, so if that store is file on a multi-server deployment, each server counts separately and the effective limit gets multiplied by the server count. Use Redis or the database store in production. The rate limiting docs cover the full API, including RateLimiter::attempt() for limiting arbitrary code paths rather than routes.
Session fixation and lifecycle
On successful login, regenerate the session ID. On logout, invalidate it and regenerate the CSRF token:
public function store(LoginRequest $request)
{
if (! Auth::attempt($request->only('email', 'password'), $request->boolean('remember'))) {
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
$request->session()->regenerate();
return redirect()->intended('/dashboard');
}
public function destroy(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
Note the generic error message. auth.failed reads "These credentials do not match our records." for both unknown emails and wrong passwords. Returning "No account with that email" is a user-enumeration oracle. The same goes for password reset: return the same response whether or not the address exists.
When a user changes their password, kill their other sessions. Laravel ships AuthenticateSession middleware for this, together with:
Auth::logoutOtherDevices($request->input('current_password'));
Password rules with breach checking
use Illuminate\Validation\Rules\Password;
Password::defaults(function () {
return $this->app->isProduction()
? Password::min(12)->uncompromised(3)
: Password::min(8);
});
uncompromised() queries the Have I Been Pwned range API using k-anonymity. Only the first five characters of the SHA-1 hash leave your server. The argument is a threshold: uncompromised(3) rejects passwords that appear in three or more breaches. Then use Password::defaults() in your validation rules so the policy is defined in one place.
A05: Security Misconfiguration — The Production Checklist
The opening example belongs to this category, and in practice this is where audits find the most issues.
Environment flags
# Production .env — the non-negotiables
APP_ENV=production
APP_DEBUG=false
SESSION_SECURE_COOKIE=true
SESSION_ENCRYPT=true
LOG_LEVEL=warning
With APP_DEBUG=true, any unhandled exception renders the full stack trace, environment variables, and request data, including database passwords and API keys pulled from .env. Check it at deploy time instead of trusting it:
php artisan about --only=environment
The output includes Debug Mode and Environment lines. Fail the deploy if debug is enabled:
php artisan about --json | jq -e '.environment.debug_mode == false' > /dev/null \
|| { echo "APP_DEBUG is on in production"; exit 1; }
Keep .env out of the web root
The document root must be public/, not the project root. Misconfigured shared hosts that serve the project root expose /.env to anyone, and scanners request that path constantly. Even with a correct root, deny dotfiles explicitly in Nginx:
server {
root /var/www/app/public;
location ~ /\.(?!well-known).* {
deny all;
return 404;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
}
The try_files $uri =404 line matters. Without it, older PHP-FPM setups with cgi.fix_pathinfo=1 can execute an uploaded avatar.jpg/x.php as PHP.
Security headers middleware
Laravel sends no security headers by default. A small middleware covers the essentials:
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);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
if ($request->isSecure()) {
$response->headers->set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
}
$response->headers->remove('X-Powered-By');
return $response;
}
}
Register it globally in Laravel 11+:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->append(\App\Http\Middleware\SecurityHeaders::class);
})
X-Powered-By: PHP/8.3.x is actually set by PHP itself, so also set expose_php = Off in php.ini. Removing the header in middleware doesn't help responses that never reach Laravel. Content-Security-Policy is worth the effort but needs per-app tuning. spatie/laravel-csp generates nonces that integrate with Vite's @vite directive.
Trusted proxies
Behind a load balancer, $request->ip() returns the balancer's address and $request->isSecure() returns false, unless you configure trusted proxies. Get this wrong in the permissive direction and rate limits keyed on IP become useless, because attackers can set X-Forwarded-For to anything:
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: ['10.0.0.0/8']);
})
Only trust '*' if the app is completely unreachable except through the proxy.
Cache config, and know what that changes
php artisan config:cache speeds up boot. It also means env() calls outside config/*.php return null in production. A check like if (env('ADMIN_IP_ALLOWLIST')) inside a middleware silently turns into "no allowlist." Read values via config() everywhere outside the config directory.
A06 and A08: Vulnerable Components and Integrity Failures
Audit dependencies in CI
Composer 2.4 added composer audit, which checks installed packages against the Packagist security advisories database (which includes GitHub advisories and the FriendsOfPHP database):
composer audit --locked --format=plain
npm audit --omit=dev --audit-level=high
It exits non-zero when advisories are found, which makes it a natural CI gate:
# .github/workflows/security.yml
name: security
on:
push:
schedule:
- cron: "0 6 * * 1"
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
tools: composer:v2
- run: composer install --no-interaction --no-progress --prefer-dist
- run: composer audit --locked
- run: npm ci && npm audit --omit=dev --audit-level=high
The weekly cron matters more than the push trigger. Advisories get published against code you haven't touched in months, and a repository that nobody pushes to never gets audited otherwise.
Composer 2.7 and later goes further. With "audit": {"abandoned": "fail"} under config in composer.json, abandoned packages also fail the audit, which catches the libraries that will never get a patch.
Signed URLs for actions without sessions
Email links for unsubscribe, download or invitation acceptance often carry an ID in the query string and nothing else. Signed routes add an HMAC so the URL can't be tampered with:
use Illuminate\Support\Facades\URL;
$url = URL::temporarySignedRoute(
'invitations.accept',
now()->addHours(48),
['invitation' => $invitation->id]
);
Route::get('/invitations/{invitation}/accept', AcceptInvitationController::class)
->name('invitations.accept')
->middleware('signed');
Changing invitation=41 to invitation=42 invalidates the signature and returns 403 Invalid signature. If a proxy or marketing tool appends utm_* parameters, the signature fails as well. Use ->middleware('signed:relative') with URL::signedRoute(..., absolute: false) when the domain differs between environments, and look at the ignore parameter of hasValidSignatureWhileIgnoring() for tracking parameters.
Never unserialize untrusted input
The integrity category exists largely because of deserialization. In PHP, unserialize() on attacker-controlled data plus a gadget chain in your vendor directory (tools like PHPGGC ship ready-made chains for Laravel, Monolog and Guzzle) means code execution. Use json_decode() for anything that crosses a trust boundary. If you truly must unserialize, restrict the allowed classes:
$data = unserialize($payload, ['allowed_classes' => false]);
The same reasoning applies to queued jobs. Laravel serializes job payloads into the queue, so anyone who can write to your Redis instance can inject a job. Put Redis on a private network with requirepass or ACLs, and never expose port 6379 publicly. The Redis security documentation explains why the default configuration assumes a trusted network.
Upload handling
File uploads combine several categories. Validate content, not just extension. Store outside public/. Generate your own filenames:
$request->validate([
'avatar' => ['required', 'file', 'mimes:jpg,png,webp', 'max:2048', 'dimensions:max_width=4000'],
]);
$path = $request->file('avatar')->store('avatars', 's3');
mimes checks the MIME type that PHP's finfo guesses from the file content, not the client-provided Content-Type. store() generates a random 40-character name, so a file uploaded as shell.php can't keep that name. Serve user files from a separate domain or through a controller that sets Content-Disposition: attachment, so an uploaded SVG containing <script> doesn't run on your origin.
A09 and A10: Logging Failures and SSRF
Log the security events that matter
Laravel fires authentication events you can listen to without touching controllers. In Laravel 11+, event discovery picks up listeners by their type-hint:
namespace App\Listeners;
use Illuminate\Auth\Events\Failed;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Support\Facades\Log;
class LogAuthFailures
{
public function handleFailed(Failed $event): void
{
Log::channel('security')->warning('auth.failed', [
'email' => $event->credentials['email'] ?? null,
'ip' => request()->ip(),
'ua' => substr((string) request()->userAgent(), 0, 200),
]);
}
public function handleLockout(Lockout $event): void
{
Log::channel('security')->error('auth.lockout', [
'ip' => $event->request->ip(),
'email' => $event->request->input('email'),
]);
}
}
Note what's not logged: $event->credentials as a whole contains the attempted password. Logging the full credentials array, which is easy to do when debugging, writes plaintext passwords (often a real password with a typo) into log files that tend to have weaker access controls than the database.
Give security events their own channel so they can be shipped and retained separately:
// config/logging.php
'security' => [
'driver' => 'daily',
'path' => storage_path('logs/security.log'),
'level' => 'info',
'days' => 90,
],
Worth logging, at a minimum: failed and successful logins, lockouts, password and email changes, 2FA enable and disable, authorization denials on sensitive resources, and admin impersonation. A 403 from a policy is worth logging too. One user generating dozens of AuthorizationExceptions across sequential IDs is an IDOR scan in progress. You can catch those in bootstrap/app.php:
use Illuminate\Auth\Access\AuthorizationException;
->withExceptions(function (Exceptions $exceptions) {
$exceptions->report(function (AuthorizationException $e) {
Log::channel('security')->notice('authz.denied', [
'user' => auth()->id(),
'path' => request()->path(),
]);
});
})
Laravel doesn't report AuthorizationException to the default log because it is in the internal don't-report list. The explicit report callback above still runs, which is exactly the behavior you want here: a dedicated trail without noise in laravel.log.
Server-Side Request Forgery
Any feature that fetches a user-supplied URL is an SSRF candidate: webhooks, link previews, "import from URL", avatar-by-URL, PDF generation from HTML. On AWS, a request to http://169.254.169.254/latest/meta-data/iam/security-credentials/ from the server returns temporary IAM credentials under IMDSv1. Internal admin panels on localhost:8080, Redis on 127.0.0.1:6379 and Elasticsearch on 10.0.0.5:9200 all become reachable through your app.
Checking the hostname string isn't enough. http://2130706433/ is 127.0.0.1 in decimal, http://127.1/ also resolves to loopback, and DNS can return a private address for a public-looking name. Resolve first, check every resolved address, then pin the connection to the address you checked so a second DNS lookup can't return something different (DNS rebinding):
namespace App\Support;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use InvalidArgumentException;
class SafeHttp
{
public static function get(string $url): Response
{
$parts = parse_url($url);
if (! in_array($parts['scheme'] ?? '', ['http', 'https'], true) || empty($parts['host'])) {
throw new InvalidArgumentException('Only http(s) URLs with a host are allowed.');
}
$host = $parts['host'];
$port = $parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80);
$ips = gethostbynamel($host) ?: [];
if ($ips === []) {
throw new InvalidArgumentException("Could not resolve {$host}.");
}
foreach ($ips as $ip) {
$public = filter_var(
$ip,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
if ($public === false) {
throw new InvalidArgumentException("Refusing to fetch internal address {$ip}.");
}
}
return Http::timeout(5)
->withoutRedirecting()
->withOptions([
'curl' => [CURLOPT_RESOLVE => ["{$host}:{$port}:{$ips[0]}"]],
])
->get($url);
}
}
The guard does four things. FILTER_FLAG_NO_PRIV_RANGE rejects 10/8, 172.16/12 and 192.168/16. FILTER_FLAG_NO_RES_RANGE rejects loopback, link-local (which includes 169.254.169.254) and other reserved blocks. CURLOPT_RESOLVE pins curl to the IP that was validated. withoutRedirecting() stops a public URL from 302-redirecting to an internal one. If you need redirects, follow them manually and run each hop through the same check. gethostbynamel() only returns IPv4 addresses. If your servers have IPv6 egress, resolve AAAA records with dns_get_record($host, DNS_AAAA) and validate them as well.
Defense in depth matters here more than anywhere else. At the infrastructure level, require IMDSv2 on EC2 (HttpTokens=required), which needs a PUT with a session token that simple SSRF can't produce. Egress firewall rules on the app servers are the other half.
Common Pitfalls
Disabling CSRF for a whole route group to fix one webhook. A 419 Page Expired from a Stripe webhook leads people to wrap entire api-style route groups in exclusions. Exclude only the exact path, then verify the provider's signature instead:
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['stripe/webhook']);
})
Laravel Cashier's webhook controller checks the Stripe-Signature header when STRIPE_WEBHOOK_SECRET is set. If that variable is empty, verification is skipped, so the CSRF exclusion leaves the endpoint wide open.
Trusting $request->user() in queued jobs. Jobs run without an HTTP request. Pass the user ID into the job and re-authorize inside handle(). The user may have lost access between dispatch and execution.
Policies that return true for admins through before() and break tenancy. A before() hook returning true for $user->isAdmin() skips every policy check, including the team boundary. If "admin" means "admin of their own team," the before() short-circuit gives them cross-tenant access. Return null from before() to fall through to the specific ability.
APP_DEBUG=false but Telescope still on. Laravel Telescope records requests, queries, and exceptions, including request payloads. Its gate in TelescopeServiceProvider only allows the emails listed there in non-local environments, but the provider is registered unconditionally in many apps. Either install it as a dev dependency and register it only when $this->app->environment('local') is true, or audit the gate() method. The same applies to Horizon's dashboard and any Debugbar installation. barryvdh/laravel-debugbar follows APP_DEBUG unless DEBUGBAR_ENABLED overrides it, and that override sometimes gets copied into production .env files.
Validation that runs after the side effect. $request->validate() must come before any write, upload or outbound request. That sounds obvious, but in long controllers the validation call drifts downward during refactors. Form Requests remove the problem by validating before the controller method runs.
Encrypted cookies excluded for convenience. Adding a cookie name to encryptCookies(except: [...]) so frontend JavaScript can read it also makes it tamperable. Never put anything authorization-related in an excluded cookie.
Rate limiting by IP alone on IPv6. An attacker with a /64 allocation controls about 18 quintillion addresses. Key IPv6 limits on the /64 prefix, or combine IP with a stable identifier such as the target email, as the login limiter above does.
Assuming Str::random() is fine for everything. It uses random_bytes(), so it is cryptographically secure, and that part is fine. The problem is comparing secrets with === or ==. Use hash_equals($known, $provided) for tokens and signatures so the comparison runs in constant time.
Putting It Into a Repeatable Process
One-time hardening decays. The fixes above only hold if something checks them on every change. A practical baseline:
- CI on every push:
composer audit --locked,npm audit, a static analysis pass with Larastan at level 6 or higher (vendor/bin/phpstan analyse), and the grep checks for raw queries and{!!in views. - Feature tests for authorization: for every policy, one test asserting the owner can act and one asserting a user from another team gets a 403 or 404. These are the tests that catch a removed
Gate::authorize()during a refactor. - Deploy gate: fail if
APP_DEBUGis true, ifAPP_ENVisn'tproduction, or ifphp artisan route:listshows Telescope, Horizon or Debugbar routes reachable without a gate. - Weekly scheduled audit: dependency advisories arrive whether or not you ship code.
An authorization test that catches a regressed IDOR looks like this:
use App\Models\Invoice;
use App\Models\User;
test('users cannot view invoices belonging to another team', function () {
$owner = User::factory()->create();
$intruder = User::factory()->create();
$invoice = Invoice::factory()->for($owner->team)->create();
$this->actingAs($intruder)
->get("/invoices/{$invoice->id}")
->assertNotFound();
$this->actingAs($owner)
->get("/invoices/{$invoice->id}")
->assertOk();
});
The test asserts assertNotFound() and not assertForbidden() because the global scope from the A01 section hides the record before the policy ever runs. If someone removes the scope, the test fails even though the policy would still return a 403. That is the kind of regression that matters, because it means list and search endpoints are leaking again.
Laravel's defaults protect against the bugs that ruled the 2000s: SQL injection through string concatenation, unescaped output, plaintext passwords and missing CSRF tokens. What's left is the application-specific work. That means deciding who owns which record, which URLs the server is allowed to fetch, which configuration ships to production, and which events leave a trail. None of it is complicated, but each piece has to be built deliberately, tested, and checked again on every deploy.
Keep reading
Production-Ready Authentication: A Deep Dive into JWT, OAuth2, and Session Security
21 min · 1,125 views
Artificial IntelligenceAdvanced Security Measures for Protecting Against Sophisticated Threats
18 min · 1,044 views
Web DevelopmentMastering Multi-Tenant SaaS Architecture Patterns for Enterprise Applications
27 min · 779 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
Get the AI-Assisted Developer's Field Guide
The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log In