Blog
FiveM Event Security: Rate Limiting Net Events and Stopping Trigger-Spam Exploits
Your Net Events Are Doors, and Executors Don’t Knock
Somewhere in your server files right now, there is an event that takes an item name and a price from the client and just… believes it. FiveM event exploit protection exists because cheaters found that event years ago, and they have been ringing every doorbell in your city with a crowbar since. One Lua executor, one event name, and your economy suddenly owns a printing press it never applied for.
This guide walks the full defensive stack: how insecure net events get exploited, how to validate everything server-side, how to rate limit triggers per player, and how to log and punish abuse without kicking an innocent player who double-clicked a button on 200 ping.
What Is FiveM Event Exploit Protection?
FiveM event exploit protection means treating every networked event as untrusted input. Register only the events that must cross the network, validate the sender and every argument server-side, rate limit calls per player, and log and punish abuse. Client-side checks are convenience, not security. The server makes every final decision.
How a Net Event Actually Gets Exploited
The moment you call RegisterNetEvent on the server, that event becomes a public API endpoint. Any connected player running an executor can fire it with TriggerServerEvent, with any arguments they like, from a console you will never see. Event names are not secret either. Client scripts stream to the player’s machine, so a cheater can dump every resource you run and read your trigger names like a lunch menu.
Here is the classic victim, found in more shop scripts than anyone wants to admit:
-- the "please rob me" pattern
RegisterNetEvent('shop:buyItem')
AddEventHandler('shop:buyItem', function(itemName, price)
local src = source
removeMoney(src, price) -- the CLIENT chose the price. Bold move.
giveItem(src, itemName, 1)
end)
An exploiter triggers shop:buyItem with a price of -50000 and the shop starts paying them to shop. That is not a purchase, that is a sponsorship deal. Swap “shop” for “job payout”, “crafting result” or “admin give” and you have the anatomy of nearly every FiveM economy wipe.
This applies to every resource you run, hand-written or purchased. Reputable marketplaces like scripts-tebex.io stock scripts with server-side checks built in, but audit every event handler you install anyway. One weak resource exposes the whole city.
Server-Side Validation: The Rule That Outranks Everything
The fix is a mindset before it is code: the client reports, the server decides. The official Cfx.re server security docs hammer the same point. Never let a networked event hand you money amounts, prices, item names, coordinates or permission flags without checking them against what the server already knows.
Step one is refusing to network events that never needed it. AddEventHandler on its own registers a same-context event that clients cannot reach. Only RegisterNetEvent opens the door to the network. Every event you keep local is an exploit you never have to defend. On the client side, the same idea applies in reverse: a client handler can check source == 65535 to confirm an event genuinely came from the server.
Step two is validating the events that remain. Here is the same shop, grown up:
local SHOP_COORDS = vector3(25.7, -1347.3, 29.5)
local SHOP_ITEMS = { -- the server's price list, the ONLY price list
['lockpick'] = { price = 250 },
['radio'] = { price = 400 },
['bandage'] = { price = 120 },
}
RegisterNetEvent('shop:buyItem', function(itemName)
local src = source -- capture BEFORE any Wait() or callback
if not src or src <= 0 then return end
local item = type(itemName) == 'string' and SHOP_ITEMS[itemName]
if not item then
return LogAbuse(src, 'shop:buyItem', 'unknown item: ' .. tostring(itemName))
end
local dist = #(GetEntityCoords(GetPlayerPed(src)) - SHOP_COORDS)
if dist > 10.0 then
return LogAbuse(src, 'shop:buyItem', ('bought from %.0fm away'):format(dist))
end
if getMoney(src) < item.price then return end
removeMoney(src, item.price)
giveItem(src, itemName, 1)
end)
Notice what moved. The price now lives in a server table, not in the packet. The item name must exist in a whitelist. The player has to physically stand near the shop. And source gets copied into a local immediately, because after a yield it can change under you. [switches to serious face] That last one is a genuinely evil bug that has handed one player’s purchase to another. Never touch source after a Wait().
We will build that LogAbuse helper in a minute. It earns its keep.
Rate Limiting Net Events Per Player
Validation stops bad arguments. Rate limiting stops good arguments arriving four hundred times a second. An exploiter who cannot fake the price can still replay a legitimate trigger, a crafting tick, a reward claim, a payout event, far faster than any human, and your database gets to feel every single one.
The simplest tool is a per-player cooldown table, perfect for events with a natural rhythm like buying or crafting:
local lastUse = {}
local function onCooldown(src, event, ms)
local key = event .. ':' .. src
local now = GetGameTimer()
if lastUse[key] and (now - lastUse[key]) < ms then
return true -- called again too fast
end
lastUse[key] = now
return false
end
-- inside the handler, before any real work:
if onCooldown(src, 'shop:buyItem', 1500) then
return LogAbuse(src, 'shop:buyItem', 'cooldown spam')
end
A Sliding-Window Limiter You Can Paste In
Some events deserve a burst allowance instead of a flat cooldown. Opening an inventory three times in five seconds is a human; thirty times is a script. A windowed counter handles that:
local buckets = {}
-- allow `limit` calls per `windowMs`, per player per event
local function rateLimited(src, event, limit, windowMs)
local now = GetGameTimer()
buckets[src] = buckets[src] or {}
local b = buckets[src][event]
if not b or (now - b.start) > windowMs then
buckets[src][event] = { start = now, count = 1 }
return false
end
b.count = b.count + 1
return b.count > limit
end
-- without this, the table grows forever. RAM is not a renewable resource.
AddEventHandler('playerDropped', function()
buckets[source] = nil
end)
That playerDropped cleanup is the line most guides forget. Skip it and every player who ever connected leaves a little souvenir in memory, which on a busy public server adds up to a slow leak you will chase for weeks. The limiter itself is a table lookup and a comparison, effectively free at runtime. That is the same discipline that separates optimized, resmon-friendly resources from the ones that eat your frame budget: do the cheap check first, bail early, touch the database only when the request has earned it.
Why the Built-In Overflow Kick Is Not Enough
FXServer already kicks clients that flood reliable events, the infamous “Reliable network event overflow” disconnect, and the neteventlog console command will show you incoming triggers live while you hunt a spammer. Treat both as transport-level backstops. They protect the network buffer, not your game logic. A cheater firing a payout event once per second will never trip the overflow kick, while your own limiter flags them on trigger three.
Whitelist Arguments, Not Just Events
Every argument that crosses the network deserves the border-control treatment: papers, please.
- Strings: type-check, length-cap, then match against a server-side whitelist. Item names, shop IDs, job names and vehicle models should all be keys into a table you control.
- Numbers: type-check, range-check, and reject the weird ones.
math.type(n) == 'integer'filters out the NaN and float tricks executors love. - Tables: be suspicious of nested payloads. Cap sizes, and never iterate an untrusted table into database writes.
- Anything naming a price, amount or reward: ignore the client’s copy entirely and read the server’s.
The same rules apply to callbacks. ox_lib callbacks and framework triggers in ESX or QBCore are still net events under the hood, wearing a nicer jacket. An executor can invoke a server callback directly, so validate inside the callback exactly as you would inside a raw handler.
Log It, Strike It, Then Drop It
Now the punishment policy, and here is the unpopular opinion: do not instaban on the first flag. Lag spikes replay events, UI buttons double-fire, and a stressed netcode frame can make an honest player look guilty for exactly one trigger. Instant bans on single flags are how you spend your weekend in ban-appeal tickets. Strikes fix that:
local strikes = {}
function LogAbuse(src, event, detail)
strikes[src] = (strikes[src] or 0) + 1
local license = GetPlayerIdentifierByType(src, 'license') or 'unknown'
print(('^1[abuse]^0 %s (%s) on %s: %s [strike %d]')
:format(GetPlayerName(src), license, event, detail, strikes[src]))
-- ship it to your log channel / DB here
if strikes[src] >= 3 then
DropPlayer(src, 'Kicked: suspicious event activity.')
end
end
AddEventHandler('playerDropped', function()
strikes[source] = nil
end)
Log the identifiers, the event name, the offending detail and the strike count. Repeat offenders across sessions graduate from kicks to bans, and the log gives your admin team evidence instead of vibes. Two cautions: truncate logged arguments so a cheater cannot flood your logs with megabyte strings, and route alerts somewhere a human actually looks. Pairing this with proper admin and moderation tooling, the kind stocked at cfx-tebex.store, turns a log line into a same-day ban instead of a next-month discovery.
What Does Not Protect You
- Renaming events to gibberish. Client files stream to the player. Obscurity is a speed bump with delusions of grandeur.
- Token handshakes alone. Randomized event tokens raise the effort bar, but an executor living inside the client can read what the client reads. Use them as a layer, never as the plan.
- Client-side checks alone. Great for UX, bypassed in one line by anyone who matters.
- Escrow. Protecting the seller’s code does nothing about who may call the seller’s events.
Lock the Doors Before You Decorate
Every hardening job on a FiveM server comes back to the same four moves: network only what must be networked, validate sender and arguments against server truth, rate limit per player with cleanup on disconnect, and log abuse with strikes before you swing the banhammer. None of it needs a paid anticheat to start; it needs an evening and a little paranoia.
Do that, and the next executor kid who goes ringing doorbells finds every door locked, a camera over each one, and a kick message waiting on strike three. Let them sponsor someone else’s shop.