On a Livewire payroll screen, a public $userId set in mount() is edited in the browser before Save - why doesn't the snapshot checksum stop it, and what does?
answer
- snapshot signed, updates are not
- HMAC-SHA256 keyed with the app key
- #[Locked] throws CannotUpdateLockedPropertyException
- a model property rehydrates from signed meta
- Locked blocks the client, not your code
basics
~20 sThe checksum signs only the snapshot the server issued; property updates travel beside it by design, since that is how wire:model works. #[Locked], a model-typed property, or an authorization check in the save action stops the edit.
solid answer
~40 sEach Livewire request carries the last snapshot plus separate lists of property `updates` and method `calls`. The checksum, an HMAC-SHA256 of the snapshot keyed with the app key, proves the snapshot is untouched, but updates are meant to be client-chosen: that is how `wire:model` works. So an update setting `userId` to someone else's id passes verification and is applied before `save()` runs. Mark the property `#[Locked]` and Livewire throws `CannotUpdateLockedPropertyException`, an empty 419 in production, before any action runs. Alternatively hold `public User $employee`: model properties rehydrate from the class and key in the signed snapshot, so the update cannot swap them. Either way, still authorize in `save()`, because `#[Locked]` does not stop your own code assigning untrusted input.
code
php · 26 lines<?php
use App\Models\Payout;
use App\Models\User;
use Livewire\Attributes\Locked;
use Livewire\Component;
class PayoutForm extends Component
{
#[Locked]
public int $userId;
public string $amount = '';
public function mount(User $user): void
{
$this->userId = $user->id;
}
public function save(): void
{
$this->authorize('pay', User::findOrFail($this->userId));
Payout::create(['user_id' => $this->userId, 'amount' => $this->amount]);
}
}go deeper
Know that any public Livewire property can be changed from the browser, and that #[Locked] makes one read-only from the client side.
Explain the request shape: a signed snapshot plus unsigned updates and calls, and why wire:model depends on updates being client-chosen.
Demonstrate the attack and its fixes: lock identifiers, prefer model properties, keep authorization in the action, and spot public setters that reopen a locked value.
Weigh default-mutable state against developer ergonomics, and decide which conventions, such as locking every identifier or banning public setters, a team should enforce.
## Two channels in one request A **Livewire update request** is how the browser talks back to a component after the first render. Its JSON holds, per component: - **`snapshot`**: the state the server produced last time: `data` (public property values), `memo` (component id, name, path, children and other metadata) and a **`checksum`**. - **`updates`**: a map of property paths to new values, collected from `wire:model` inputs, `$wire` writes in Alpine, or anything else on the page. - **`calls`**: the actions to run, by method name and parameters. The **snapshot checksum** is an HMAC-SHA256 over the snapshot's JSON (minus the `children` memo), keyed with the application's encryption key, the `APP_KEY`. On each request Livewire recomputes it and compares with `hash_equals`; a mismatch throws `CorruptComponentPayloadException`. Repeated failures from one IP are rate-limited (ten within ten minutes, then a 429). This stops anyone from forging the snapshot: changing a property's recorded value, swapping a model's class, or rewriting the memo. It does **not** cover `updates`. Those are, by design, what the user chose. Livewire applies them on top of the verified snapshot, runs the updating/updated hooks, and only then runs the calls. ## The payroll attack ```php public $userId; // set in mount() from the route public $amount; public function save() { Payout::create(['user_id' => $this->userId, 'amount' => $this->amount]); } ``` The page never renders an input for `userId`. It does not matter: a user can add `wire:model="userId"` to any element in devtools, or edit the outgoing request's `updates` to `{"userId": 812}`. Livewire checks only that `userId` is a public property declared on the component, sets it, then calls `save()`. The checksum passed, because the snapshot was not touched. ## Three defences | Defence | How it works | What it does not cover | |---|---|---| | `#[Locked]` on the property | Any client update to it, or to a nested key like `filters.team`, throws `CannotUpdateLockedPropertyException` before calls run | Your own PHP assigning it from untrusted input | | Store the model: `public User $employee` | The snapshot keeps the class and key; on update Livewire rebuilds from that signed meta and ignores the client value; writing `employee.salary` throws | Whether this user may act on that employee at all | | Authorize in the action | `save()` checks the current user may pay `userId` | Nothing, if every action does it, but it is easy to forget | In production, with `app.debug` false, `CannotUpdateLockedPropertyException` renders an empty **419**, so the tamperer learns nothing. In debug mode you see "Cannot update locked property: [userId]". ## Why not lock everything, or use protected? Livewire keeps public properties mutable by default because it cannot tell which ones your templates or Alpine code bind; parsing every view would still miss custom JavaScript. **Protected properties** are not an answer either: Livewire persists only public properties between requests, so a protected `$userId` set in `mount()` is gone on the next request. ## Checksum failures that are not attacks `CorruptComponentPayloadException` also fires for innocent reasons, and a senior answer tells them apart from tampering: - **A key change.** Snapshots signed before an `APP_KEY` rotation fail after it; Livewire's checksum uses only the current key, not `app.previous_keys`. - **Servers that disagree.** Two app servers behind one load balancer with different `APP_KEY` values reject each other's snapshots at random. - **A proxy or extension rewriting the page.** Anything that edits the `wire:snapshot` attribute in transit breaks the signature. In production the exception's own `report()` method tells Laravel's handler it was handled, so it is not written to the application log, and the user gets an empty 419. A burst of 419 responses from the update endpoint right after a deploy therefore points at configuration, not at an attacker, and you will find it in access logs rather than the app log. ## What a strong answer adds 1. **Locked is read-only, not secret.** The value still sits in the page's snapshot for anyone to read. 2. **Locked guards the client path only.** A method like `setEmployee($id) { $this->userId = $id; }` is public and callable, so it reopens the hole. Keep assignments to locked properties in `mount()` or protected code. 3. **The key matters.** Because the checksum is keyed with the `APP_KEY` only, rotating that key breaks every open page's next request with a checksum failure until users reload. 4. **Defence in depth.** Lock the identifier and still authorize in `save()`; route middleware and policies change over time, and a lock does not express who may pay whom.
- Does a Livewire #[Locked] property stop a public method from changing it?No. `#[Locked]` hooks only the client update path: any update to the property in the request payload throws. PHP code in the component can still assign it, so a public `setUser($id)` action that writes `$this->userId = $id` lets the browser change it anyway. Assign locked properties only in `mount()` or protected code, from trusted values.
- After an APP_KEY rotation, users with open Livewire pages get errors on their next click. Why?The snapshot checksum is an HMAC keyed with the current encryption key. Snapshots rendered before the rotation were signed with the old key, and Livewire's checksum uses only the current one, so verification fails with `CorruptComponentPayloadException`, a bare 419 in production. A page reload issues a fresh snapshot.
- What happens if a Livewire request edits a value inside the signed snapshot instead of sending an update?The recomputed HMAC no longer matches, so Livewire throws `CorruptComponentPayloadException` before hydrating anything. In production it returns an empty 419, and the failure counts toward a per-IP limit: after ten failures in ten minutes, further requests get a 429.
A signed delivery note and a sticky note: the courier checks the warehouse's stamp on the delivery note, but the sticky note the customer adds saying 'deliver to flat 12 instead' is not stamped, and only a rule at the door refuses it.
saying these in an interview costs you the question
- The snapshot checksum stops users from changing any public property.
- A property with no input in the template cannot be updated.
- Making the property protected keeps its value safely between requests.
- #[Locked] also hides the value from the browser.
- Locked properties cannot be changed even by the component's own methods.