Originally published at hafiz.dev
Every public property in your Livewire component is sent to the browser. Every single one. The snapshot that Livewire uses to maintain state between requests includes every public property value in plain JSON. Your users can see them, modify them, and send them back to your server.
Most Laravel developers don't think about this. They write public $userId the same way they'd write a protected property on any other PHP class. The difference is that a regular PHP property lives only on the server. A Livewire public property lives on both sides, and the client side isn't yours.
This post covers what can go wrong, what already went wrong in production for thousands of apps, and how to audit your own components in about 30 minutes.
How the snapshot works
When Livewire renders a component, it serializes all public properties into a JSON snapshot that gets embedded in the page. On every subsequent request (a button click, a form submission, a wire:model update), the browser sends that snapshot back to the server. Livewire hydrates the component from the snapshot, applies the update, and sends a new snapshot back.
The attack vector is simple: the snapshot travels through the browser, and the browser is controlled by the user.
Anything in that snapshot can be changed before it comes back. IDs, status flags, prices, permissions, whatever your public properties hold.
The concrete attack
Here's a component that looks normal but is vulnerable:
use Livewire\Component;
class EditProfile extends Component
{
public $userId;
public $name;
public $email;
public function mount($userId)
{
$this->userId = $userId;
$user = User::find($userId);
$this->name = $user->name;
$this->email = $user->email;
}
public function save()
{
User::find($this->userId)->update([
'name' => $this->name,
'email' => $this->email,
]);
}
}
The problem: $userId is public. A user loading their own profile page sees their own ID in the snapshot. They open DevTools, change the ID to another user's ID, and the next save() call updates someone else's profile. No authentication bypass needed. No SQL injection. Just modifying a JSON value the server trusts implicitly.
This pattern shows up constantly in real codebases. Any component where a public property determines whose data gets read or written is vulnerable if the property isn't locked or the action doesn't re-authorize.
CVE-2025-54068: proof this matters
In July 2025, a critical vulnerability (CVE-2025-54068) was disclosed in Livewire v3 versions 3.0.0-beta.1 through 3.6.3. The flaw was in the property hydration mechanism itself, allowing unauthenticated attackers to achieve remote code execution by crafting malicious property updates. No authentication required. No user interaction needed.
The vulnerability was patched in v3.6.4. If you're running anything older, update immediately.
The broader lesson from this CVE is that the property hydration pipeline is a real attack surface. The RCE was the most severe example, but garden-variety property manipulation (changing IDs, toggling flags, modifying amounts) is something any developer with browser DevTools can do right now against your components.
Three ways to protect your components
1. Lock properties that shouldn't change
Livewire v3 introduced the #[Locked] attribute for exactly this problem. When a property is locked, any attempt to modify it from the client throws an exception.
use Livewire\Attributes\Locked;
use Livewire\Component;
class EditProfile extends Component
{
#[Locked]
public $userId;
public $name;
public $email;
// ...
}
Now if someone modifies $userId in the snapshot, Livewire rejects the request before your code even runs. This is the simplest fix for any property that gets set during mount() and should never change after that.
Don't forget the import: use Livewire\Attributes\Locked;. Missing it is a silent failure.
2. Use Eloquent models instead of IDs
Livewire automatically protects Eloquent model IDs when you store the full model as a property:
class EditProfile extends Component
{
public User $user;
public function mount(User $user)
{
$this->user = $user;
}
public function save()
{
$this->user->update([
'name' => $this->name,
'email' => $this->email,
]);
}
}
Livewire ensures the model's ID can't be tampered with. No #[Locked] needed. This is the recommended pattern for most components that operate on a single model. If you're storing $postId or $userId as a plain integer, ask yourself why you're not storing the model instead.
3. Authorize in every action method
Even with locked properties, action parameters are still modifiable. The wire:click="delete({{ $post->id }})" in your Blade template sends the post ID as an argument, and that argument can be changed in the browser.
Always authorize:
public function delete($postId)
{
$post = Post::findOrFail($postId);
if ($post->user_id !== auth()->id()) {
abort(403);
}
$post->delete();
}
Never trust that the value coming from the browser is the same value you rendered. Treat every Livewire action parameter exactly like you'd treat a POST request parameter. Validate and authorize every time.
The 30-minute audit
Run these searches against your Livewire components. Each one finds a potential vulnerability.
Find unlocked ID properties:
grep -rn 'public \$.*[Ii]d' app/Livewire/ | grep -v '#\[Locked\]'
Any result that stores an ID without #[Locked] or without being a full Eloquent model binding is a candidate for fixing.
Find action methods that trust their parameters:
grep -rn 'function delete\|function update\|function remove\|function approve' app/Livewire/
Check each result: does the method verify that the authenticated user has permission to perform the action on the specific resource? If it goes straight from parameter to database query without authorization, it's vulnerable.
Find components that use $this->someId in queries without authorization:
grep -rn 'find(\$this->' app/Livewire/
Every find($this->someId) should be followed by an authorization check, or the property should be #[Locked].
Once you've fixed the patterns, write Pest tests that attempt to tamper with locked properties and verify the exceptions fire. Automated tests catch regressions when someone removes a #[Locked] attribute without realizing what it protects.
For a broader security audit covering Composer dependencies and supply chain risks, the Composer audit guide walks through the process of checking what you've actually installed.
What Livewire does and doesn't protect
A few things Livewire handles for you that are worth knowing:
Middleware re-application. If your Livewire component is loaded via a route with authorization middleware (like can:update,post), Livewire re-applies that middleware on every subsequent request. So a user who loads the page but then loses permission will be blocked on the next interaction.
Model property IDs. As mentioned, storing a full Eloquent model as public User $user protects the model ID automatically.
Checksum validation. Livewire signs its snapshots with the application key. This prevents wholesale snapshot forgery. But it doesn't prevent modification of individual property values, the checksum covers the snapshot's structure, not the content of mutable properties.
What Livewire does NOT protect: plain public property values (integers, strings, booleans), action method parameters, and any property you don't explicitly lock.
FAQ
Should I lock every public property?
No. Properties bound to wire:model need to be mutable. Lock the properties that get set in mount() and should never change: IDs, user references, permission flags, anything that determines whose data the component operates on.
Does #[Locked] work on Livewire 4?
Yes. The attribute exists in both Livewire v3 and v4. The import is use Livewire\Attributes\Locked; in both versions.
Can I use protected properties instead?
Protected properties don't persist between Livewire requests. They're fine for static values you set once and never need again, but any runtime data that must survive between user interactions has to be a public property. That's why #[Locked] exists: it gives you the persistence of a public property with the safety of a protected one.
Is this only a problem with Livewire, or does Inertia have the same issue?
Inertia sends props to the frontend too, but Inertia props are read-only on the client. The client doesn't send them back on subsequent requests. Livewire's two-way sync is what creates the attack surface. Inertia forms use explicit POST requests with validated data, so the pattern is fundamentally different.
My app is behind authentication. Am I still at risk?
Yes. The attack doesn't require being unauthenticated. A logged-in user can modify their own component's snapshot to access or modify another user's data. Authentication proves who someone is, authorization proves what they're allowed to do. You need both.
The takeaway
Every time you write public $something in a Livewire component, ask yourself one question: what happens if the user changes this value? If the answer is "something bad," lock it with #[Locked] or store the full Eloquent model instead.
The 30-minute audit above catches the most common patterns. Run it once, fix what you find, and add #[Locked] to your mental default for any property that determines data ownership.
Security at the application layer and security at the server layer are different problems. For the infrastructure side, the VPS hardening guide covers closing ports, hiding your IP, and locking SSH. Both layers matter.
Got a Livewire app you want audited? Get in touch.