BOLA in a Laravel Livewire app: when client-side state is the only thing standing between users and admin actions

A penetration test landed an interesting finding on a Livewire-powered admin panel I work on. The summary on the report read: Broken Object-Level Authorization (BOLA). A standard user can change a tenant-wide “who can access these assets” setting by replaying an administrator’s Livewire request. Severity: Low. Impact: High.

That gap between severity and impact is what made the finding interesting. “Low” because exploitation requires capturing a snapshot from an admin’s session — non-trivial. “High” because the moment you have one, a regular user becomes effectively an administrator. 🪓

What the tester actually did

Two browsers, side by side.

Browser A: logged in as a tenant administrator. Open the asset access settings page, flip the toggle, click Save. While the request is in flight, capture the Livewire snapshot — the JSON blob Livewire posts to /livewire/update containing the component class, the new value, and the cryptographically-signed snapshot of component state. This is normal browser-DevTools work.

Browser B: log out of the admin session. Log in as a plain unprivileged user. Replay the captured request from Browser A, with Browser B’s session cookie. The server processes it. The toggle flips. The standard user has just changed a tenant-wide setting.

The Livewire snapshot’s signature checks out — the snapshot itself is valid. What it’s missing is any check that the user submitting the request is actually allowed to perform the action it represents.

Why this happens in Livewire specifically

If you’ve built REST controllers in Laravel, you’ve reflexively put authorization at the top of your action methods:

1
2
3
4
5
public function update(Request $request, Asset $asset)
{
    $this->authorize('update', $asset);
    // ...
}

Livewire components don’t pattern-match this in your head the same way. The methods you write in a Livewire class — save(), delete(), toggleAccess() — feel like internal helpers. They’re public methods on a PHP object, not endpoints. But Livewire makes them exactly that: every public method is reachable from the browser via a signed snapshot replay. If you don’t authorize them server-side, nothing else does. Blade conditionals that hide UI elements only hide UI elements. The endpoint is open.

The mental shift: every public method on a Livewire component is a controller action, and deserves the same authorization treatment. 🛡️

The fix pattern

I went through every Livewire component in the project and applied the same three-step pattern.

1. Authorize in mount() for the whole component

If a component shouldn’t even be rendered for unauthorized users, fail fast in mount(). This handles the “don’t load it” half of the problem and short-circuits replay attacks against the form itself:

1
2
3
4
5
6
public function mount($context)
{
    $this->authorize('asset:list');
    $this->context = $context;
    // ...
}

2. Authorize on every action method

For each public method that mutates state — save, update, delete, toggleSomething — add an authorize call at the top. Don’t trust that mount() already gated the component, because a replay attack hits the action method directly without re-running mount():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public function deleteUser(): void
{
    $this->authorize('user:delete');
    // ... actual deletion
}

public function validateAndSaveUser(): void
{
    if ($this->context === 'createUser') {
        $this->authorize('user:create');
        // ...
    } else {
        $this->authorize('user:edit');
        // ...
    }
}

Note the pattern in the second example: the same component handles two different operations (create and edit) with different ability strings. The authorization check goes inside each branch, so the right ability is enforced for each.

3. Use a base class so it’s the default, not the exception

Across a few dozen components, it’s easy to miss one. We introduced a thin base class that all our Livewire components extend, which trait-includes a customized authorize():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
namespace App\Auth;

use Illuminate\Foundation\Auth\Access\AuthorizesRequests as BaseAuthorizesRequests;

trait LivewireAuthorizesRequests
{
    use BaseAuthorizesRequests {
        authorize as baseAuthorize;
    }

    public function authorize($ability, $arguments = [])
    {
        return auth()?->user()?->canAccess($ability)
            || $this->baseAuthorize($ability, $arguments);
    }
}

Two small things going on. First, we alias Laravel’s stock authorize to baseAuthorize so we can fall through to it. Second, our app has a custom canAccess on the user that consults a role-ability map living in config/roles.php. The trait gives Livewire components both checks — our app’s role abilities, and stock Laravel policies — with one consistent call site.

4. The harder case: object-scoped authorization

Some abilities are global (“can this user create assets at all?”). Others are per-object (“can this user edit this specific campaign?”). The second one is closer to OWASP’s actual definition of BOLA — the object-level part. We added a sibling helper:

1
2
3
4
5
6
public function authorizeGroupedObject($ability, $groupedObject, $arguments = [])
{
    return (auth()?->user()?->canAccess($ability)
        && $groupedObject?->isAdminAuthorized(auth()->user()))
        || $this->baseAuthorize($ability, $arguments);
}

Used like:

1
$this->authorizeGroupedObject('campaign:edit', $this->campaign);

Both conditions must hold: the user has the role-level ability, and the user has access to the specific group/tenant/owner that this object belongs to. Without the second check, a user who has “campaign:edit” globally could replay a snapshot to edit a campaign in someone else’s group — exactly the BOLA pattern, just with the object identifier in the snapshot instead of the action.

Tests for replay attacks specifically

The most useful thing I added wasn’t the fix — it was a test file that simulates the exact attack. Roughly:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function test_standard_user_cannot_replay_admin_asset_toggle()
{
    $admin = $this->createTenantAdmin();
    $standardUser = $this->createStandardUser();

    // Standard user calls the action directly, as a replay would.
    $this->actingAs($standardUser);
    Livewire::test(AssetsAccess::class, ['context' => 'settings'])
        ->call('save', true)
        ->assertForbidden();

    // And the underlying setting is still the original value.
    $this->assertFalse(Setting::firstWhere('key', 'can_access_assets')->value);
}

The point of this test isn’t that the UI hides the button from the user — that’s not what’s being verified. The point is the action method itself, when called by an unauthorized actor, refuses. That’s the only assertion that catches a replay attack.

I wrote one of these for every component I touched. Feature tests, not unit tests, and Livewire’s Livewire::test() harness makes them concise.

Lessons

  • A signed snapshot is not an authorization check. Livewire’s signature proves the snapshot wasn’t tampered with. It does not prove the current user is allowed to use it. These are different properties; the framework provides the first, you provide the second.
  • Every public method on a Livewire component is a public endpoint. Reason about it the same way you would a controller action. “This is only called from my own Blade view” is wrong — it’s called by anyone who can construct a request to /livewire/update.
  • Hiding UI is not enough. A @can directive in Blade hides a button. It does not protect the action behind the button. Both are needed; only the second one is security.
  • Bake authorization into the base class. If “add $this->authorize(…) to every public method” is a convention, you’ll forget. If authorize is a trait method on the base class and there’s a code review checklist, you’ll forget less. If you go a step further and write a static analyzer that flags Livewire action methods with no authorize() call, you’ll forget least.
  • Test replays directly. Don’t only test the happy path “admin can do thing” and the sad path “button doesn’t show for non-admin.” Also test “non-admin calls the action method directly and is rejected.” That’s the test that maps to the actual attack.

The pentest report rated this Low severity because the attacker needs an admin’s snapshot. In practice, the gap between “can capture an admin’s snapshot” and “is an admin” is whatever the local network conditions are — a shared workstation, a malicious browser extension, a screenshare gone wrong. Do not rely on that gap. Authorize on the server, on every action, every time. 🔐

This entry was posted in Laravel, php and tagged , , , , , , . Bookmark the permalink.

Comments are closed.