Filament 5.10 Security Fixes: The Livewire Scope Gap Your Upgrade Doesn't Close
Filament fixed four security advisories in 5.10. Upgrading closes most of them, but one root cause sits in Livewire and needs your own checks.
On 8 October, Filament published four security advisories. The fixes shipped two days earlier, in Filament 5.10.0 and 4.15.0. All four are rated medium, and for most apps the answer is simple. Upgrade.
But one of them is different. The advisory about records staying reachable after they leave a user's tenant has its root cause in Livewire, not Filament. Filament 5.10 closes it for resource pages. It says plainly that relation managers, widgets and your own components are not covered. And when I ran composer audit on an affected install, it said everything was fine.
I installed Filament 5.9.0 on a clean Laravel 13.35 project, reproduced the scope problem with a plain Livewire component, and tested two fixes against it. All output below is real.
The four advisories at a glance
| Advisory | What it is | Affected | Fixed in |
|---|---|---|---|
| GHSA-6p72-cx74-63f5 | Records stay reachable after a scope or tenant change | 4.x, 5.x | 4.15.0, 5.10.0 |
| GHSA-jpwg-8rw9-v3jj | Email MFA bypass in multi-panel apps with colliding user IDs | 4.x, 5.x | 4.15.0, 5.10.0 |
| GHSA-q2ph-j76w-c7qx | FileUpload kept the client's file extension, so an image could be stored as .html |
3.x, 4.x, 5.x | 3.3.56, 4.15.0, 5.10.0 |
| GHSA-9g92-9qj5-cqjc | Pasted RichEditor custom blocks could run JavaScript | 4.x, 5.x | 4.15.0, 5.10.0 |
The current patch releases are 5.10.1 and 4.15.1, both from 7 October. If you're still on v3, only the FileUpload issue applies, and 3.3.56 fixes it.
This is the second round of Filament security fixes in two months. I covered the August round of MFA fixes when 4.12 and 5.7 shipped.
Why composer audit may say you're fine
Here's a Filament 5.9.0 install, which all four advisories affect:
composer show filament/filament | grep versions
composer audit
versions : * v5.9.0
No security vulnerability advisories found.
composer audit reads Packagist's advisory database. When I checked it on 10 October, the newest Filament entry was from 5 October. The four advisories from 8 October weren't there yet. The previous Filament advisory took 12 days to show up after GitHub published it.
So check the version yourself:
composer show filament/filament | grep versions
composer update "filament/*" -W
Why a record outlives its tenant
The advisory describes the symptom. A user opens a record. The record then moves to another tenant, or out of a scope the user is limited to. The page is still open, and the next Livewire request still loads the record. Depending on the page's actions, the user can still view or change it.
It needs an open page and existing access, so this isn't an attacker reaching into arbitrary records. But it is exactly the case multi-tenant apps worry about. Someone moves a client to another team, and the person who just lost access can keep editing from a tab they never closed.
The root cause is in Livewire
A Livewire component can't send an Eloquent model to the browser. When you declare public Project $project, Livewire stores the model's class and key in the page snapshot. On every later request, it loads the model again. This is the line that does it, from Livewire 4.4.7's ModelSynth:
$model = (new $class)->newQueryForRestoration($key)->useWritePdo()->firstOrFail();
And this is newQueryForRestoration() in Laravel 13.35:
public function newQueryForRestoration($ids)
{
return $this->newQueryWithoutScopes()->whereKey($ids);
}
newQueryWithoutScopes(). Your tenant scope runs on the first request, when the page mounts. It doesn't run when the model is restored on every request after that.
Reproducing it without Filament
To reproduce it, I added a Project model with a team scope:
protected static function booted(): void
{
static::addGlobalScope('team', function (Builder $query) {
$query->where('team_id', auth()->user()?->current_team_id);
});
}
And a plain Livewire component with a public model property:
class ShowProject extends Component
{
public Project $project;
public function rename(string $name): void
{
$this->project->update(['name' => $name]);
}
}
The test mounts the component as a user on team 1. It then moves the project to team 2 and calls rename() from the still-open component:
$component = Livewire::test(ShowProject::class, ['project' => $project])
->assertSee('Roadmap');
DB::table('projects')->where('id', $project->id)->update(['team_id' => 2]);
dump(Project::find($project->id));
$component->call('rename', 'Renamed after the move');
dump(DB::table('projects')->where('id', $project->id)->first(['team_id', 'name']));
null
{#3176
+"team_id": 2
+"name": "Renamed after the move"
}
The scoped query says the project doesn't exist for this user. The component renamed it anyway.
What Filament 5.10 fixes, and what it doesn't
The fix (PR #20616) adds a Livewire component hook. On every request it loads the record again through the resource's own query, scopes included, and returns a 404 if the record is no longer there. This is the hook's skip() method in 5.10.1:
public function skip(): bool
{
return ! (($this->component instanceof ResourcePage) || ($this->component instanceof EditTenantProfile));
}
Resource pages and the tenant profile page. Nothing else. The advisory says the same: the fix "does not automatically revalidate models passed into separate components, such as relation managers or widgets. Applications must enforce any required scope restrictions when those components access the models."
Here's where that leaves each kind of component after the upgrade:
So after you upgrade, three places still need your attention.
Relation managers need an owner-record check
A relation manager holds its parent in public Model $ownerRecord, so the same restoration happens. Filament 5.10 does add one useful thing here (PR #20587). canViewForRecord() now runs before the relation manager mounts, and again before every later Livewire update. If it returns false, the request gets a 403.
The default implementation only checks viewAny on the related model class. It never asks whether the owner record still belongs to the current tenant. So add that yourself:
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Model;
public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
{
return $ownerRecord->team_id === Filament::getTenant()?->getKey()
&& parent::canViewForRecord($ownerRecord, $pageClass);
}
Adjust the comparison to however your tenant relationship is named. The point is to check the record you were given, not to trust a query that already ran without your scope.
Widgets have no record in their static check
A widget's canView() is static and receives no record, so it can't do this check. If a widget declares public ?Model $record, verify it in hydrate(). The next section shows that pattern.
Two fixes for your own Livewire components
I ran both against the same scenario. Each one stopped the rename, and the name stayed "Roadmap".
Option 1: keep the key, resolve through the scope on every request. The model never becomes a public property, so Livewire never restores it without scopes:
#[Locked]
public int $projectId;
public function mount(Project $project): void
{
$this->projectId = $project->id;
}
#[Computed]
public function project(): Project
{
return Project::findOrFail($this->projectId);
}
After the move, the next request returned 404. findOrFail() runs through the global scope, so the project simply isn't found.
Option 2: keep the model property, check ownership in hydrate(). Livewire calls hydrate() on every request after the first, before any action runs:
public Project $project;
public function hydrate(): void
{
abort_unless($this->project->team_id === auth()->user()->current_team_id, 403);
}
After the move, the next request returned 403. The restored model carries the fresh team_id from the database, so the check sees the move straight away.
I prefer option 1 for new code. It can't be forgotten in one action, because the scope is the only way to get the model. Option 2 is the smaller change for an existing component.
The same lesson applies to any Livewire public property. What the browser holds between requests is a reference, and the server rebuilds it on its own terms. Authorization belongs in a policy or an explicit check that runs on every request, not in a scope that only ran once.
The multi-panel MFA bypass
This one only affects apps with more than one panel, where the panels use different auth guards and different user models, and email-based MFA is on.
Filament stored the email code and its send rate limit under keys built only from the user's primary key. Two panels share one browser session. So an admin with ID 1 and a customer with ID 1 shared the same MFA state. The advisory explains how an attacker who knows the victim's password and controls a colliding account could use that shared state to pass the victim's email challenge. App-based MFA and single-panel apps aren't affected.
The fix scopes all of this state by guard, user model class and identifier. Filament 5.10 also exposes the helper it uses, which is handy if you've built a custom MFA provider:
use Filament\Facades\Filament;
$principal = Filament::getUserScopedAuthIdentifier($user);
The same mistake is easy to make in your own code. If your app has two guards (say web for customers and admin for staff), any key built from $user->id alone can collide. That covers session keys, cache keys and rate limiter keys. Include the guard and the model class:
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
$guard = Auth::getDefaultDriver();
RateLimiter::attempt(
'send-code:'.$guard.':'.$user::class.':'.$user->getAuthIdentifier(),
maxAttempts: 3,
callback: fn () => $this->sendCode($user),
);
One related detail from the MFA docs. Filament keeps challenge and replay-protection state in your default cache store, and uses an atomic lock when the store supports one. Without lock support, concurrent requests may accept the same code twice. So if you run MFA on more than one server, every server needs the same cache backend, and it should support locks (database and Redis both do).
If you're building a multi-tenant Filament panel with a separate customer panel, this is the combination the advisory describes.
The two XSS fixes
Both are closed by upgrading.
FileUpload kept the client's extension. Filament generated a random file name, but took the extension from the name the browser sent. Validation checks the file's detected MIME type. So a file that passed as an image could be stored as .html. If uploads go to a public disk served from your app's own domain, opening that URL could run JavaScript. The fix switches to Laravel's hashName(), which takes the extension from the detected MIME type instead. This affects v3 as well, which makes it the one fix to backport.
Two things to know. preserveFilenames() still uses the client's file name on purpose, so only use it for trusted uploaders. And files uploaded before you upgrade keep their names. It's worth a quick look for active extensions on your public disk:
find storage/app/public -type f \( -name '*.html' -o -name '*.htm' -o -name '*.svg' -o -name '*.xml' \)
RichEditor trusted pasted block previews. Custom blocks carry a preview in a data-preview attribute. When someone pasted HTML containing a custom block, the editor rendered that preview without sanitizing it. Copying from a malicious page into the editor was enough. The fix regenerates previews on the server instead of trusting the pasted ones.
The upgrade checklist
- Check the installed version with
composer show filament/filament. Don't rely oncomposer audituntil these advisories reach Packagist. - Upgrade to 5.10.1 or 4.15.1. For v3, upgrade to at least 3.3.56.
- Search your relation managers for
canViewForRecord(). If records are tenant-scoped, add an ownership check on$ownerRecord. - Search for
public ?Model $recordand other public model properties in widgets and custom Livewire components. Either resolve them through a scoped query, or check ownership inhydrate(). - If you run several panels with email MFA, confirm your cache store supports atomic locks. Then search your own code for session, cache and rate limiter keys built from a user ID alone.
- Look for
.html,.svgand similar files on the public disk that were uploaded throughFileUpload.
If your panel picked up custom widgets and pages over time, step 4 is the one to schedule properly.
FAQ
Which Filament versions are affected by the October 2026 advisories?
All four advisories affect Filament 4.x before 4.15.0 and 5.x before 5.10.0. The FileUpload extension issue also affects 3.x before 3.3.56. The current patch releases are 5.10.1 and 4.15.1.
Why doesn't composer audit report the new Filament advisories?
composer audit uses Packagist's advisory database, which receives GitHub advisories with a delay. On 10 October, the four advisories published on 8 October weren't in it yet. Check your installed version directly until they appear.
Does upgrading to Filament 5.10 fix the tenant scope issue completely?
For resource pages and the tenant profile page, yes. For relation managers, widgets and custom Livewire components, no. The advisory says applications must enforce scope restrictions there themselves.
Do Livewire components apply global scopes when restoring a model property?
No. Livewire restores public model properties with newQueryForRestoration(), which uses newQueryWithoutScopes(). Global scopes run when the page mounts, but not on later requests. Resolve the model through a scoped query, or check ownership in hydrate().
Who is affected by the Filament multi-panel MFA bypass?
Apps with two or more panels that use different auth guards and user models, share a browser session, and have email-based MFA enabled. App-based MFA and single-panel apps aren't affected.
Where the time goes
Steps 1 and 2 take five minutes. Steps 3 and 4 are where the time goes, and they're also the ones no update will do for you.
About Hafiz
Senior Full Stack Developer. I build production software with Laravel, Filament, Vue, and AI integrations, and write about the real decisions behind shipping it.
Get in touch →Get web development tips via email
Join 50+ developers • No spam • Unsubscribe anytime
Related Articles
Filament v4.12 and v5.7: 92% Faster Forms, and the Security Fixes You Still Have to Turn On
Filament's big performance release landed. The speed is free. The security half...
Every Livewire Public Property Is a Form Field: The Security Audit Every Laravel App Needs
Your Livewire public properties are visible in the browser and modifiable by any...
Filament v5 Released: What's New, What Changed, and Should You Upgrade?
Filament v5 dropped on January 16th with Livewire v4 support. Here's what actual...