Open source October 2026
Wirefence

Wirefence

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 TypeError raised 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 with mod_remoteip): trust no proxy in Laravel, which is its default. Trusting '*' here makes Laravel believe the X-Forwarded-For the 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 allow list, 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