Wirefence

Wirefence protects a Livewire application from the forged requests bots send to its update endpoint. It looks at every update and every call before Livewire assigns or runs anything, answers the forged ones with a 400 and one warning line in the log, and blocks the address that sent them from the whole application for a day.
Why it exists
Every Livewire component talks to the server through one endpoint. Each request carries a snapshot of the component, the properties that changed and the methods to call. The snapshot is signed, so a modified one is rejected, but the updates and the calls are plain data. A bot copies a valid snapshot from any public page and writes the rest itself.
They are looking for Livewire versions affected by CVE-2025-54068, the remote code execution fixed in 3.6.4. On an updated application they get nothing, but every attempt still ends as a 500 with its stack trace, and a night of them buries the real errors:
[2026-11-07 01:22:20] production.ERROR: Cannot handle file upload without [Livewire\WithFileUploads] trait on the [pages.home] component class. [2026-11-07 01:22:20] production.ERROR: Cannot handle file upload without [Livewire\WithFileUploads] trait on the [pages.contact] component class.
What gives a request away
Each check runs as a "before" listener of Livewire's update and call hooks, ahead of Livewire's own listeners, so it behaves the same on Livewire 3 and 4, Volt components included. A page that works never trips one:
| Signal | What arrived | Blocks after |
|---|---|---|
type_mismatch |
an array or object for a property typed string, int, float or bool, nullable or in a union of those |
1 |
locked_property |
an update to a #[Locked] property |
1 |
unknown_property |
an update to a property the component does not have, or that is not public | 3 |
unknown_method |
a call to a method the component does not have, or that is not public | 3 |
upload_probe |
an upload call to a component without WithFileUploads, or to any component when the application takes no files |
1 |
unknown_property and unknown_method take three strikes within ten minutes because a tab left open across a deploy that renamed a property or a method can send the old name once or twice.
Livewire's internal calls, the ones starting with $ or _ ($refresh, $set, __dispatch...), are never counted. The four upload calls (_startUpload, _finishUpload, _uploadErrored, _removeUpload) are the exception: they are methods of WithFileUploads, so they are internal only on a component that uses it.
Some things look like a probe and are left to Livewire on purpose:
- Text typed into a number field is a person's mistake.
- A
TypeErrorraised by your own component code is your bug, not a forged request. - An array sent to an untyped property, or to one typed
array, is what a group of checkboxes sends.
What happens then
Every forged request is answered with a 400 (status) and one warning line with the address, the signal, the component and the property or method. Strikes are counted per address and per signal within window_minutes. When an address reaches the signal's threshold, it is blocked for block_minutes, a day by default, and a second line says so:
[2026-11-14 17:02:02] local.WARNING: wirefence: tampered Livewire request. {"ip":"203.0.113.7","signal":"upload_probe","component":"pages.contact","target":"_startUpload","count":1,"threshold":1}
[2026-11-14 17:02:03] local.WARNING: wirefence: address blocked. {"ip":"203.0.113.7","signal":"upload_probe","until":"2026-11-15T17:02:02+00:00","strikes":2}
From then on every request from that address, Livewire or not, gets a 403 (blocked_status) with an empty body. The check is the last global middleware: it runs after Laravel's trusted proxies have resolved the visitor's address, and before sessions, the database or Livewire, so a blocked bot costs one cache lookup.
strikes counts how many times the address has been blocked, so a bot that comes back after a day shows up as a repeat.
Applications that take no files
If no component in the application takes files, say so in the published config. It is not read from the environment, because taking files is part of the application's code:
'uploads' => false,
Every upload call then answers 404, as if Livewire had no uploads, and counts as an upload_probe, so the address is blocked at once. Livewire's upload and preview routes answer 404 too, without a strike, since nothing about a request to them is forged. The routes are matched by name (livewire.upload-file and livewire.preview-file), because Livewire 4 builds their paths.
With uploads on, the default, uploads work as always on components that use WithFileUploads.
The visitor's address
Wirefence blocks the address Laravel returns from $request->ip(), the same one the rate limiters use. Behind Cloudflare, nginx or a load balancer, that address depends on the trusted proxies, and getting them wrong is worse with Wirefence than without it:
- Your web server already restores the visitor's address (nginx with
real_ip_header, Apache withmod_remoteip): trust no proxy in Laravel, which is its default. Trusting'*'here makes Laravel believe theX-Forwarded-Forthe visitor writes, so anyone can pick their own address, skip every rate limit and, with Wirefence, get somebody else's address blocked for a day. - Laravel receives Cloudflare's addresses: trust only Cloudflare's ranges, never
'*'.
If you cannot change that, turn on WIREFENCE_CLOUDFLARE=true. A request coming from one of Cloudflare's ranges is then judged by its CF-Connecting-IP header, which nobody else can use to impersonate a visitor, and Cloudflare's own addresses are never blocked. It only changes what Wirefence sees: fixing the trusted proxies also fixes the rate limiters.
To check it, take any route with a rate limit, such as throttle:20,1, and send it four requests: one plain, two with a made-up X-Forwarded-For and one plain again:
for ip in "" 203.0.113.77 203.0.113.78 ""; do curl -s -D - -o /dev/null ${ip:+-H "X-Forwarded-For: $ip"} https://example.com/ping | grep -i x-ratelimit-remaining done
If the four share one counter, the address cannot be forged:
x-ratelimit-remaining: 19 x-ratelimit-remaining: 18 x-ratelimit-remaining: 17 x-ratelimit-remaining: 16
If every made-up address starts a counter of its own, Laravel is believing the visitor:
x-ratelimit-remaining: 19 x-ratelimit-remaining: 19 x-ratelimit-remaining: 19 x-ratelimit-remaining: 18
Addresses it never blocks
Blocking the wrong address would lock out real users, so some are never blocked, only answered with the 400:
- Private and reserved addresses. Seeing one means the application does not see its visitors' real address, and blocking it would block everybody behind it. The warning line says so:
wirefence: tampered Livewire request from an address it never blocks (private, allowed or Cloudflare): check that the application sees its visitors' real address. - The
allowlist, addresses or CIDR ranges. - Cloudflare's own ranges, when the Cloudflare option is on.
Installation
It is on Packagist and auto-discovered, so installing it and migrating its table is all it takes:
composer require edulazaro/wirefence php artisan migrate
PHP 8.2, Laravel 12 or 13 and Livewire 3.6.4 or later, or 4. Earlier versions of Livewire 3 carry the vulnerability the bots are looking for, so the package does not install next to them. There is no JavaScript.
Configuration
Every threshold and duration can be changed by publishing the config file:
php artisan vendor:publish --tag=wirefence-config
| Key | Default | |
|---|---|---|
enabled |
true (WIREFENCE_ENABLED) |
off, nothing is inspected or blocked and Livewire behaves as without the package |
signals.* |
1, 1, 3, 3, 1 | strikes before blocking, in the order of the table above; 0 turns a signal off |
window_minutes |
10 | the window strikes are counted in |
uploads |
true |
off, every upload call and Livewire's upload and preview routes answer 404 |
block_minutes |
1440 | how long a block lasts |
status / blocked_status |
400 / 403 | the answers to a forged request and to a blocked address |
keep_days |
30 | days a record stays after its block ended; null keeps them |
allow |
[] |
addresses or ranges never blocked |
cloudflare.enabled |
false (WIREFENCE_CLOUDFLARE) |
judge Cloudflare's requests by CF-Connecting-IP |
cloudflare.ranges |
Cloudflare's published ranges | the addresses that count as Cloudflare |
cache_store / log_channel |
null |
the application's defaults |
A signal at 0 still answers the forged request with its 400 (or the 404 for uploads); it only stops counting strikes.
Commands
Three commands cover what an administrator needs. wirefence:list shows the addresses blocked right now, and --all adds the blocks that have ended:
php artisan wirefence:list
+-------------+--------------+---------------+--------------+---------+---------------------+ | IP | Signal | Component | Target | Strikes | Blocked until | +-------------+--------------+---------------+--------------+---------+---------------------+ | 203.0.113.7 | upload_probe | pages.contact | _startUpload | 2 | 2026-11-15 17:02:02 | +-------------+--------------+---------------+--------------+---------+---------------------+
With nothing blocked it prints No address is blocked right now., and with --all and an empty table, No address has been blocked.
wirefence:unblock lifts a block at once, from the cache and the table, and clears the address's strikes. The row stays as the record of what happened:
php artisan wirefence:unblock 203.0.113.7
203.0.113.7 unblocked.
An address that was not blocked prints 203.0.113.7 was not blocked., and anything that is not an IP address is refused.
wirefence:prune deletes the records whose block ended more than keep_days ago. It runs every day through Laravel's scheduler, so it needs schedule:run in the crontab; without it, run it by hand. With keep_days set to null it keeps everything.
Events
Every block dispatches EduLazaro\Wirefence\Events\ClientBlocked, with the address, the signal, the component, the property or method, the end of the block and how many times the address has been blocked. A listener can turn it into a mail or a Slack message:
use EduLazaro\Wirefence\Events\ClientBlocked; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Mail; Event::listen(function (ClientBlocked $event) { Mail::raw( "Wirefence blocked {$event->ip} until {$event->until->toDateTimeString()}.\n" ."Signal: {$event->signal->value}. Component: {$event->component}. Target: {$event->target}.", fn ($message) => $message->to('admin@example.com')->subject("Address blocked: {$event->ip}"), ); });
Storage
The cache answers "is this address blocked" on every request, so a visitor who is not costs one cache lookup and nothing else. Strikes live in the cache too, and expire with their window.
The wirefence_blocks table is the record behind it: one row per address, with what gave it away the last time, the component and target, how many times it has been blocked and until when. If the cache is cleared, a blocked bot gets one more try, and that try blocks it again. If the table cannot be written, for instance before migrating, the block still holds in the cache.
An address is personal data, so rows whose block ended more than keep_days ago are pruned every day.
Testing your application
To check that your own components are covered, post a forged update to Livewire's real endpoint, as a bot would. Livewire::test() gives a snapshot with a valid signature, and only the update is made up. The address comes from 203.0.113.0/24, a documentation range, because the default address in tests is private and would never be blocked:
use App\Livewire\EditProfile; use App\Models\User; use Livewire\Livewire; public function test_a_forged_update_is_refused_and_the_address_blocked(): void { $user = User::factory()->create(); $snapshot = Livewire::actingAs($user)->test(EditProfile::class, ['user' => $user])->snapshot; $this->actingAs($user) ->withServerVariables(['REMOTE_ADDR' => '203.0.113.7']) ->withHeaders(['X-Livewire' => 'true']) ->postJson(app('livewire')->getUpdateUri(), [ 'components' => [[ 'snapshot' => json_encode($snapshot), 'updates' => ['name' => ['not', 'text']], 'calls' => [], ]], ]) ->assertStatus(400); $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.7'])->get('/')->assertForbidden(); $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.8'])->get('/')->assertOk(); }
A caveat for your components
A property typed string bound to something that sends an array, such as a <select multiple> or a group of checkboxes, is a bug in the component, and Wirefence takes its requests for probes. Type such properties array. The same goes for a file input on a component without WithFileUploads, which Livewire already answers with a 500.
built and maintained by Edu Lazaro · MIT license