What a net event really is
It is tempting to read RegisterNetEvent('bank:withdraw', ...) as part of your own
script, a thing your UI calls when the player clicks a button. The server does not see it that way.
It sees a function published to every connected machine, callable at any time, with any arguments,
by anyone.
A Lua executor does not need to exploit anything to use it. It just calls it. If the handler takes an amount and adds it to a balance, the amount is whatever the caller decided, including a number with a lot of zeroes or a negative one that inverts a subtraction.
This is why a server can be entirely free of "cheats" in the aimbot sense and still be economically ruined by lunchtime.
The five checks
A request from your own UI
- origin: passed
- schema: passed
- rate: passed
- server state: passed
- idempotency: passed
Reaches the handler, which reads every value it needs from the server rather than from the payload.
The same event, fired from an executor
- origin: refused
- schema: not reached
- rate: not reached
- server state: not reached
- idempotency: not reached
Refused at the first gate and written down. The four checks behind it never run, and neither does the database query at the end of them.
| Check | Rule | What it prevents |
|---|---|---|
| 1 · Origin | A source of 65535 means the server triggered it. Reject that on a handler meant for clients. | Server-only paths being driven from a client. |
| 2 · Schema | Type, range, length and enum whitelist for every argument. No implicit casts. | Negative amounts, absurd quantities, wrong types, injected tables. |
| 3 · Rate | A token bucket per player per event. | Loops that call a legitimate action ten thousand times a second. |
| 4 · State | Balance, position, job, inventory read on the server. Never from the payload. | The entire category of "the client said it had the money". |
| 5 · Idempotency | A transaction token that can be spent exactly once. | Replaying one valid request until the economy breaks. |
The one people skip
Check four is the one that matters most and gets left out most often, because it does not look like a security check. It looks like a refactor. Any value the handler needs must be read on the server: the balance from the database, the position from the entity, the job from the session.
If a payload field describes the world rather than the request (how much money the player has, where they are standing, what role they hold) it is not an argument, it is a claim, and it does not belong in the handler at all.
Why the order matters
Run the cheap checks first. Origin and schema are a few comparisons; a state lookup may be a database round trip. Under a flood, which is exactly when you need the handler to survive, the difference between rejecting a malformed call after two comparisons and after a query is the difference between a shrug and an outage.
Log every rejection with the player, the event, the check that failed and the raw value. Without that record you cannot tell an attack from a bug in your own UI, and you cannot tune anything later. What you should not do is tell the player which check failed. That turns your rejection messages into a free debugging service for whoever is probing you.
State bags count too
Events are the obvious surface. State bags are the one people forget: a client can write to them, and a flood of writes is a genuine crash vector. Rate limit and validate per key, and treat any server-side decision made from a client-written bag value the same way you would treat a payload field, as a claim, not a fact.
Start with an inventory
The first honest answer to "how many net events does your server expose?" is almost always "no idea". A grown ESX or QBCore install runs to several dozen resources, each registering handlers written by different people over several years.
So the first step is not securing events. It is counting them: a static sweep of every server and shared script for registrations, expanded through any wildcard entries in the manifests, because a large share of resources load their modules with a glob and those files are invisible to a naive scan.
On the reference server we used while building SwisserAC, resolving globs took the count from 95 registered events to 138. Three job resources were completely invisible until it was done. The list itself is the point: it is the first time most servers have ever seen their own attack surface written down.
Rolling it out without breaking things
Validation added carelessly breaks legitimate gameplay, and a broken server gets the validation removed rather than fixed. Stage it:
- Inventory every event, including glob-loaded files.
- Observe in report-only mode for long enough to see real traffic. A week covers the weekly rhythm of a roleplay server.
- Declare a schema per event from what was actually observed, not from what you assume.
- Reject in report-only first. Read what would have been rejected, and fix your own scripts.
- Enforce, one group of events at a time.
It is slower than switching on a detection list, and it is the part of anticheat work that actually holds. A cheat signature expires when the cheat updates. An event that the server refuses to execute stays refused.
This is also, deliberately, the largest single piece of work in the SwisserAC architecture, not the detection rules on top of it.