Skip to content
Go back

I Asked AI to Audit My Laravel App's Security. It Found 4 Things I'd Never Have Caught.

I’ve been building my Laravel app for over a year. It has authentication, team-based permissions, user roles, and shared reports. I assumed it was reasonably secure because I’m using Jetstream, Spatie Permissions, and Livewire — all well-regarded packages with security built in.

Then I asked AI to do a full security audit. It found four categories of vulnerability I wouldn’t have caught on my own.

1. Users could view other teams’ data by changing an ID

The audit flagged five Livewire components where models were fetched by ID without verifying the current user had permission to access them. The technical term is IDOR — Insecure Direct Object Reference — and it means a user can modify an ID in a request to access data that doesn’t belong to them.

In Livewire, there are two main attack surfaces. Public properties (unless they store a full Eloquent model or use #[Locked]) can be tampered with between requests by modifying the Livewire payload in the browser. And action parameters passed via wire:click="method(123)" can be changed to any value in DevTools.

Here’s what I asked:

if this is the preference, why don’t we just move more to this approach? Store full Eloquent models as properties (public Post $post) — Livewire locks the model ID automatically. Or is that not possible in all cases?

The answer: model properties prevent ID tampering, but they don’t check authorisation. You always need an explicit $this->authorize() call. Model locking is defence-in-depth, not a replacement for authorisation. And it doesn’t fit every case — you can’t type-hint public User $user when the model doesn’t exist yet in create mode.

Before — fetching a subscriber by an unscoped, user-controlled ID:

public function delete()
{
    $subscriber = Subscriber::find($this->confirmingDeleteId);
    // ...
}

After — scoping the lookup through the team relationship:

public function delete()
{
    $subscriber = auth()->user()->currentTeam
        ->subscribers()
        ->where('subscribers.id', $this->confirmingDeleteId)
        ->first();

    if (! $subscriber) {
        $this->confirmingDeleteId = null;
        $this->modal('confirm-subscriber-deletion')->close();
        return;
    }
    // ...
}

The same pattern was applied across all five components. Every Model::find($id) with a user-controlled ID was replaced with a team-scoped query, and $this->authorize() calls were added to action methods — not just mount().

The fix also added #[Locked] to properties storing IDs that shouldn’t be tampered with, and abort_unless checks for team membership:

public function mount(Idea $idea)
{
    abort_unless($idea->team_id === auth()->user()->current_team_id, 403);
    $this->idea = $idea;
}

2. Comments were vulnerable to XSS

The comments system used strip_tags() to sanitise input, allowing only <br>, <a>, and <span> tags. Then the output was rendered with {!! !!} — Blade’s unescaped output syntax.

// Saving the comment
$content = strip_tags($this->comment, '<br><a><span>');
<!-- Rendering the comment -->
{!! App\Helper\Mentions::convertMentionsToHtml($comment->content) !!}

I asked:

is this really needed? does laravel not provide protections out of the box for XSS like this? what would taylor otwell do?

Laravel does protect against XSS — Blade’s {{ }} syntax automatically escapes output with htmlspecialchars(). The problem is the code opted out of that protection by using {!! !!}. And strip_tags() is a well-known PHP footgun — it strips tags but leaves attributes on allowed tags intact, so it can’t be relied on for security.

The idiomatic Laravel pattern when you need to render some HTML but keep user text safe is:

  1. Escape everything first with e() (Laravel’s htmlspecialchars helper)
  2. Then convert known-safe patterns (mentions, line breaks) into HTML
  3. Output with {!! !!} — now safe because you control exactly what HTML exists

This is the same approach Laravel uses internally for mail markdown rendering.

3. All 10 models used $guarded = []

Ten core models — including Idea, Goal, Comment, and Share — all had protected $guarded = [];, which tells Laravel to allow every attribute to be mass-assigned with no restrictions.

please investigate the following issue: HIGH — Mass assignment ($guarded = []) on 10 models. These core models allow all attributes to be mass-assigned.

The assistant used an analogy that stuck with me: $guarded = [] is like removing the lock from an internal door. Right now the front door (Livewire validation) is locked, so nobody gets in. But if someone ever leaves the front door open by accident, there’s nothing stopping them from walking straight through.

Taylor Otwell addressed this directly in the August 2020 security release for Laravel 6.18.35 / 7.24.0. That release was prompted by a real vulnerability: if foo was listed in $guarded, an attacker could still mass-assign foo->bar using a JSON nesting expression. Taylor’s recommendation:

As a personal recommendation, I recommend always using $fillable instead of $guarded. In general, $fillable is a safer approach to mass assignment protection because you are forced to list any new columns added to the database to the $fillable array.

Before:

class Comment extends Model
{
    protected $guarded = [];
}

After:

class Comment extends Model
{
    protected $fillable = [
        'idea_id',
        'user_id',
        'content',
    ];
}

This was repeated across all 10 models. I also added Model::preventSilentlyDiscardingAttributes() in AppServiceProvider to catch any missing fillable fields early:

public function boot(): void
{
    Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
}

This throws an exception in local and staging environments if any code tries to mass-assign an attribute that isn’t in $fillable.

4. Share tokens were predictable

Three identical copies of a custom random string function were scattered across the codebase, all using mt_rand():

private function getRandomString($length = 8)
{
    $characters = '0123456789abcdefghijklmnopqrstuvwxyz';
    $string = '';
    for ($i = 0; $i < $length; $i++) {
        $string .= $characters[mt_rand(0, strlen($characters) - 1)];
    }
    return $string;
}

mt_rand() uses the Mersenne Twister algorithm — a pseudorandom number generator that’s not cryptographically secure. If someone observes enough outputs, they can predict future values.

Custom codeLaravel convention
Functionmt_rand() loopStr::random()
Randomness sourceMersenne Twister (predictable)random_bytes() (OS entropy)
Lines of code8 lines, duplicated 3 times1 line
Charactersa-z, 0-9 (36)a-zA-Z, 0-9 (62)

The fix was one line: $hash = Str::random(30);. All three duplicate methods were deleted, along with the UtilityServiceProvider class that existed solely to hold one of them. As the audit pointed out, a ServiceProvider is not a utility class — if register() and boot() are both empty, it shouldn’t exist.

What I learned

The common thread across all four findings was the same: the framework already had the right solution, and my code was working around it.

None of these were dramatic, front-page security breaches. They were quiet gaps — the kind that accumulate when you’re building fast and don’t have a security background. Running the audit caught them before they became real problems.


Back to top ↑