← Back to Blog

Generic security scanners were built for Java enterprise apps. Here's what they miss when you run them against Laravel.


Why Your Scanner's Report Is Lying to You

You run your SAST tool. It spits out 200+ findings. You triage for a few hours, mark most of them "false positive," and the real issues — the ones that will actually get you breached — quietly ship to production anyway.

The problem isn't that generic scanners are bad at finding vulnerabilities in the abstract. The problem is they don't understand your framework. They see PHP tokens, not Laravel concepts. They don't know what route model binding is. They don't understand multi-tenancy patterns. They've never heard of $fillable.

After building HAVOC — a framework-aware security scanner built specifically for Laravel — we've catalogued the patterns that bite teams the hardest. Here are five real security mistakes that generic scanners consistently miss, and what framework-aware scanning actually catches.


1. Unscoped Route Model Binding

Route model binding is one of Laravel's genuinely great features. Define a route parameter, type-hint the model in your controller, and Laravel automatically resolves the model from the database. Clean, readable, less boilerplate.

It's also one of the most common vectors for IDOR (Insecure Direct Object Reference) vulnerabilities — because it's easy to forget that resolving a model isn't the same as authorizing access to it.

The vulnerable pattern

// routes/web.php
Route::get('/invoices/{invoice}', [InvoiceController::class, 'show'])
    ->middleware('auth');

// InvoiceController.php
public function show(Invoice $invoice)
{
    return view('invoices.show', compact('invoice'));
}

This looks fine. The route is auth-protected. Laravel resolves the invoice. But which invoice? Any invoice. If user A knows user B's invoice ID (or just increments the URL parameter), they can read user B's invoice.

Why generic scanners miss it

Generic SAST tools see Invoice $invoice and have no idea what it means. They don't understand route model binding as a concept. They might flag the missing SQL query (there isn't one in the controller — Laravel handles it internally), or they might flag nothing at all because there's no obviously dangerous function call. Either way, they don't catch the authorization gap.

What framework-aware scanning catches

HAVOC understands route model binding. It knows that when Laravel resolves {invoice} to an Invoice model, the controller is now holding a model instance that the user may or may not own. It then checks whether the controller or policy validates ownership before using the model.

HAVOC flags this as IDOR and points you to the fix:

public function show(Invoice $invoice)
{
    // Option 1: Gate check
    $this->authorize('view', $invoice);

    // Option 2: Manual ownership check
    abort_unless($invoice->user_id === auth()->id(), 403);

    return view('invoices.show', compact('invoice'));
}

Or, scope at the route level using Laravel's scopeBindings():

Route::get('/invoices/{invoice}', [InvoiceController::class, 'show'])
    ->middleware('auth')
    ->scopeBindings();

The fix is simple. Finding it without a framework-aware scanner is the hard part.


2. Mass Assignment Through Nested Relationships

Laravel's $fillable and $guarded properties exist to prevent mass assignment — a classic vulnerability where an attacker passes extra fields in a request to overwrite fields they shouldn't control (like is_admin, role, email_verified_at).

Most developers know about mass assignment on the root model. What's much less understood is that mass assignment protection on a parent model does not protect the model created through a nested relationship.

The vulnerable pattern

// User model — looks safe
class User extends Model
{
    protected $fillable = ['name', 'email'];
}

// UserProfile model — this is the problem
class UserProfile extends Model
{
    protected $fillable = ['bio', 'avatar', 'role']; // 'role' shouldn't be here
}

// ProfileController.php
public function store(Request $request)
{
    $user = auth()->user();
    $user->profile()->create($request->all()); // 🚨
}

The User model's $fillable never comes into play here. $user->profile()->create() is calling create() on the UserProfile model, and that model's fillable configuration is what determines what gets written.

An attacker who sends {"bio": "cool dev", "role": "admin"} has just promoted themselves.

Why generic scanners miss it

Generic scanners typically flag $request->all() everywhere, regardless of context. If they're smart enough to check for $fillable on the model being saved, they may look at User's (safe) $fillable and call it clean, without tracing through the relationship chain to find which model actually receives the data.

What framework-aware scanning catches

HAVOC traces the call chain. It resolves $user->profile() to the UserProfile model via the relationship definition, then checks that model's $fillable/$guarded against the fields that could arrive through $request->all(). If the nested model is unguarded or has sensitive fields in $fillable, it flags it.

The fix — validate before creating, and keep $fillable tight:

public function store(Request $request)
{
    $validated = $request->validate([
        'bio'    => 'nullable|string|max:500',
        'avatar' => 'nullable|url',
        // 'role' is intentionally absent
    ]);

    auth()->user()->profile()->create($validated);
}

3. Cache Key Tenant Leakage

Multi-tenant Laravel apps often use a shared cache store. Used correctly, this is fine — Laravel's cache is fast, and there's no reason to spin up a Redis instance per tenant. Used carelessly, it's a data isolation nightmare.

The pattern shows up constantly in codebases that started single-tenant and grew:

The vulnerable pattern

// SettingsService.php
public function getSettings(): array
{
    return Cache::remember('settings', 3600, function () {
        return Setting::all()->keyBy('key')->toArray();
    });
}

In a single-tenant app: no problem. In a multi-tenant app where every organization shares the same cache store: Organization A's settings are cached under the key settings, and Organization B retrieves Organization A's settings on their next request.

This one is particularly nasty because it's silent. No errors, no exceptions — just the wrong data, served confidently.

Why generic scanners miss it

Generic scanners don't know what a "tenant" is. They see Cache::remember('settings', ...) and have nothing to flag — it's valid PHP, valid Laravel, no dangerous function calls, no user input going into a dangerous sink. There's no signature to match. The vulnerability is entirely semantic: the key doesn't include a tenant identifier.

What framework-aware scanning catches

HAVOC understands multi-tenancy patterns. It looks for common tenant-scoping packages (Spatie Multitenancy, Tenancy for Laravel, Stancl/Tenancy), custom tenant middleware patterns, and Organization/Tenant scope traits. When it finds a multi-tenant codebase, it flags cache operations that use static keys without tenant scoping.

The fix is a one-liner, but you have to know to apply it:

public function getSettings(int $orgId): array
{
    return Cache::remember("org_{$orgId}_settings", 3600, function () use ($orgId) {
        return Setting::where('organization_id', $orgId)
            ->get()
            ->keyBy('key')
            ->toArray();
    });
}

4. Artisan Commands Exposed as Web Routes

This one falls into the "how did that make it to production" category, but it happens more than you'd think — often as a quick debugging tool that never got removed.

The vulnerable pattern

// routes/web.php
Route::get('/clear-cache', function () {
    Artisan::call('cache:clear');
    return 'Cache cleared!';
});

Route::get('/migrate', function () {
    Artisan::call('migrate', ['--force' => true]);
    return 'Migrated!';
});

The first one is annoying — anyone can blow your application cache, causing a thundering herd on your database as everything re-warms. The second one is catastrophic in certain scenarios. Either way, you've handed an unauthenticated endpoint to anyone with a browser.

Why generic scanners miss it

Generic scanners see Artisan::call() and may look for user input flowing into the Artisan call (command injection via argument). What they miss is the architectural problem: the route itself is web-accessible, unauthenticated, and triggers a privileged operation. The danger isn't in the arguments — it's in the exposure.

What framework-aware scanning catches

HAVOC understands Laravel's routing layer. It identifies routes that call Artisan::call() or Artisan::queue() without authentication middleware, then classifies the called command by its impact — informational, destructive, or schema-altering. Cache operations are flagged as medium severity; migration commands are flagged as critical.

The fix: gate any admin-style operations behind proper auth:

Route::get('/admin/clear-cache', function () {
    Artisan::call('cache:clear');
    return redirect()->back()->with('success', 'Cache cleared.');
})->middleware(['auth', 'can:admin']);

Better yet, do it via SSH or a secured admin panel where you have a full audit trail. Debug routes that run Artisan commands belong in development, not production.


5. Timing Attacks on API Token Comparison

This one is subtle, well-documented in security literature, and still shows up in production Laravel apps regularly.

A timing attack works by measuring how long a comparison takes. PHP's === operator returns false as soon as it finds the first non-matching character. This means comparing "abc123" to "xyz999" returns faster than comparing "abc123" to "abc124" — because the latter shares more characters before the mismatch. An attacker who can make enough requests and measure response times can use this to brute-force API tokens character by character.

The vulnerable pattern

// ApiTokenMiddleware.php
public function handle(Request $request, Closure $next): Response
{
    $token = $request->header('X-API-Token');
    $expected = config('services.api_token');

    if ($token !== $expected) {  // 🚨 timing-vulnerable
        return response()->json(['error' => 'Unauthorized'], 401);
    }

    return $next($request);
}

And it's not just middleware — it shows up in webhook signature verification too:

// WebhookController.php
$signature = $request->header('X-Webhook-Signature');
$computed = hash_hmac('sha256', $request->getContent(), config('services.webhook_secret'));

if ($signature !== $computed) {  // 🚨 timing-vulnerable
    abort(403);
}

Why generic scanners miss it

Generic scanners flag === on strings only if they have a specific rule for it — and most don't. It's valid PHP, valid comparison syntax, and there's nothing syntactically wrong with it. Without understanding the context — that this is security-sensitive token comparison — there's nothing for a pattern-matcher to latch onto.

What framework-aware scanning catches

HAVOC identifies patterns where string comparison is being used on values that originate from security-sensitive sources: config values with "token", "secret", "key", or "signature" in the name; request headers matching common auth header patterns; HMAC outputs. When it finds === or == being used to compare those values, it flags it.

// Fixed middleware
if (!hash_equals($expected, $token ?? '')) {
    return response()->json(['error' => 'Unauthorized'], 401);
}

// Fixed webhook verification
if (!hash_equals($computed, $signature ?? '')) {
    abort(403);
}

hash_equals() is PHP's constant-time string comparison function. It always takes the same amount of time regardless of where the strings diverge. One function call; timing attack vector closed.

Note the ?? '' — you also want to handle null tokens gracefully rather than letting PHP do a null === string comparison, which has its own type-juggling gotchas.


The Common Thread

Look at these five mistakes and you'll notice the pattern: they're all framework-specific concepts that require context to evaluate.

Generic scanners work with syntax trees and pattern matching. They're excellent at catching exec($userInput) and raw SQL with string interpolation. They're blind to architectural and framework-level vulnerabilities.

This is why we built HAVOC. Framework-aware scanning isn't just about fewer false positives (though that helps too) — it's about finding the real vulnerabilities that slip through every generic scan.

See what your scanner is missing

HAVOC scans your Laravel codebase for all five of these patterns — plus route authorization gaps, SQL injection, exposed secrets, CSRF bypasses, and more. The CLI is free and open-source.

npm install -g @havoc/cli
cd your-laravel-project
havoc scan
Start Free — No Credit Card → or View on GitHub