01. Security Checklist
Plain English
FiveM servers run untrusted clients. Every player is a potential attacker. Every RegisterNetEvent is a public API that any player can call from their console.
This file is the audit checklist. Run through it for every resource you write before you ship. Skip a step → potential exploit. Mass-produce-able exploits get you raided, dupes ruin your economy, leaked webhooks get you banned from Discord.
Don’t skip steps.
Rule Zero
Never trust the client for anything that matters.
“Matters” = money, items, kill scoring, position used for damage calcs, job status, admin permissions, anti-cheat triggers.
Trust the client for: display, UX state, non-critical UI inputs.
The 10 Commandments
1. Validate Every Argument On Every Net Event
RegisterNetEvent('shop:buy', function(itemId, qty)
local src = source
if not src or src <= 0 then return end -- valid source
if type(itemId) ~= 'string' then return end -- type
if #itemId > 32 then return end -- length cap
if type(qty) ~= 'number' then return end
if qty ~= qty then return end -- NaN
if qty <= 0 or qty > 99 then return end -- range
if qty % 1 ~= 0 then return end -- integer
-- only after all checks pass do you reach the actual logic
end)
Type, NaN, range, length, integer-check. Every arg. Every event.
2. Whitelist, Don’t Blacklist
-- ↓ only these item IDs are valid; anything else, bail
local VALID_ITEMS = { bread = 10, water = 5, burger = 20 }
local price = VALID_ITEMS[itemId]
if not price then return end
Never trust the client to pass a price. Client passes itemId (just a string). Server looks up the price from its own config.
3. Server Is Source Of Truth For Money
-- BAD - client decides what to charge
local price = clientSentPrice
-- GOOD - server config decides
local price = CONFIG.PRICES[itemId]
Same for: discounts, multipliers, tax rates, anything that affects the math.
4. Atomic Money Operations
-- BAD: read-then-write race
local cash = player.PlayerData.money.cash
if cash < price then return end
player.Functions.RemoveMoney('cash', price, 'buy') -- two parallel events both pass the check, both deduct
-- GOOD: framework's RemoveMoney is atomic - returns false if insufficient
if not player.Functions.RemoveMoney('cash', price, 'buy') then
return -- didn't have enough money
end
Or use a conditional MySQL UPDATE - covered in 04-database/02-queries-and-security.md.
5. Job / Role Checks On Every Privileged Event
RegisterNetEvent('police:openArmory', function()
local src = source
local player = exports.qbx_core:GetPlayer(src)
if not player then return end
if player.PlayerData.job.name ~= 'police' then return end -- must be a cop
if player.PlayerData.job.grade.level < 2 then return end -- must be grade 2+
end)
Client-side checks are UX. Server-side checks are security. Always both.
6. Distance Checks On Location-Sensitive Events
local CONFIG_COORDS = vec3(25.0, -1347.0, 29.5)
local ped = GetPlayerPed(src)
local pos = GetEntityCoords(ped) -- server reads synced position
if #(pos - CONFIG_COORDS) > 3.0 then return end -- more than 3m from shop, bail
Server reads player position from the synced entity state - not from a client-supplied arg. Stops “use shop from across the map” exploits.
7. Rate Limiting
local cooldowns = {}
RegisterNetEvent('shop:buy', function()
local src = source
local now = GetGameTimer() -- ms since server start
if cooldowns[src] and (now - cooldowns[src]) < 500 then return end
cooldowns[src] = now
-- process
end)
-- ↓ release on disconnect to avoid memory leak
AddEventHandler('playerDropped', function()
cooldowns[source] = nil
end)
Spam-firing an event = race condition opportunity + server lag. Always throttle hot events.
8. Locks For Critical Sections
local busy = {}
RegisterNetEvent('shop:buy', function()
local src = source
if busy[src] then return end -- already processing this player's buy
busy[src] = true
-- ... money + inventory operations ...
busy[src] = nil -- release the lock
end)
Combine with atomic DB ops for defense-in-depth.
9. No SQL Injection
Always parameterized queries:
MySQL.query.await('SELECT * FROM players WHERE name = ?', { name })
Never .. user input into SQL. See 04-database/02-queries-and-security.md for full coverage.
10. Logs For Money / Items
Every money change, every item add/remove:
MySQL.insert(
'INSERT INTO money_log (citizenid, delta, reason) VALUES (?, ?, ?)',
{ cid, -price, 'shop_buy_' .. itemId }
)
When (not if) an exploit happens, the log tells you who and how. Without logs, you’re guessing.
Client Event Exposure
Every RegisterNetEvent is a public API. Name doesn’t matter - secret:event is no more secret than public:event. Players can fire:
TriggerServerEvent('secret:event', anyArgs)
Treat every registered net event as if a malicious player was about to call it with the worst-possible args.
Don’t Leak Admin Events
-- BAD - any player can trigger this and give themselves money
RegisterNetEvent('admin:giveMoney', function(target, amount)
local player = exports.qbx_core:GetPlayer(target)
player.Functions.AddMoney('cash', amount)
end)
-- GOOD - gate it with ACE
RegisterNetEvent('admin:giveMoney', function(target, amount)
local src = source
if not IsPlayerAceAllowed(src, 'admin.money') then
DropPlayer(src, 'Exploit attempt') -- kick the attacker
return
end
-- now safe to proceed
end)
Better: don’t expose admin actions as net events at all. Use server commands + ACE permissions:
RegisterCommand('givemoney', function(source, args)
if source ~= 0 and not IsPlayerAceAllowed(source, 'admin.money') then return end
-- ... do the action
end, true) -- true = restricted to ACE
Then players can’t fire it from a console - only the server (source == 0) or ACE-allowed admins can run the command.
Callback Security
lib.callback.register is a net event under the hood. Same rules apply:
lib.callback.register('shop:price', function(src, itemId)
if type(itemId) ~= 'string' then return nil end
return CONFIG.PRICES[itemId] -- whitelist via config table lookup
end)
Don’t Send Secrets To The Client
Anything in client_script or files{} is publicly readable. Never include:
- Discord webhook URLs
- DB credentials
- Admin passwords
- Third-party API keys
- Anti-cheat detection logic that could be reverse-engineered
Anything sensitive → server-only files, loaded via convars (next section).
Webhook Hygiene
Discord webhooks in client (or even readable shared) files = exploitable. Attacker spams the webhook with garbage → Discord bans the webhook. Your moderation logging dies.
Store in a convar:
# server.cfg
set my_webhook "https://discord.com/api/webhooks/12345/abcdef"
Read on the server:
local webhook = GetConvar('my_webhook', '') -- 2nd arg = default if convar missing
Convars are server-only. Never bundled with client downloads.
Entity Ownership
The client that “owns” an entity can modify its state. If you spawn something server-side and want server-controlled state, use:
-- ↓ server-side spawn - server-owned entity, harder to modify client-side
local veh = CreateVehicleServerSetter(modelHash, 'automobile', x, y, z, heading)
Qbox / newer wrappers expose helpers that handle this for you - check your framework docs.
Kick On Confirmed Exploits
Don’t silently return on obvious malicious input. Kick:
if bogusArg or impossibleState then
DropPlayer(src, 'Exploit attempt: ' .. eventName) -- shows in player's disconnect screen
return
end
Plus log it. Repeat offenders need bans, not patience.
Anti-Cheat Note
Many servers run third-party anti-cheats (txAdmin tools, paid solutions, community resources like FiveSafe). Don’t rely on them as sole defense. Server-side validation comes first; anti-cheat is a second layer that catches the things validation can’t (memory edits, injected DLLs).
Pre-Ship Audit Checklist
Before shipping a resource, walk through this list:
- Every
RegisterNetEventvalidates types AND ranges - Every
RegisterNetEventchecks permission (job/ACE) where needed - Money values come from server config, never from client args
- All money ops use atomic framework functions or conditional UPDATEs
- All SQL queries use
?parameters - Distance checks on location-sensitive events
- Rate limit on hot events
- Locks on critical sections
- No webhooks in client/shared files
- No secrets in client/shared files
- Logs in DB for money/inventory changes
-
onResourceStopcleanup (NUI focus, threads, entities, blips) - Tested with malicious inputs from F8 console
Red Flags When Reading Existing Code
If you see any of these in a public/leaked resource, don’t use it without fixing:
RegisterNetEventbody without ANY validation = guaranteed exploit- Client passing
price,amount,totalas event args = guaranteed exploit - Job check happens client-side only = guaranteed exploit
TriggerClientEvent('addItem', ...)with no server-side accounting = guaranteed exploitMySQL.query("... " .. clientInput .. " ...")= SQL injection- Hardcoded webhook URL anywhere = will get spammed and banned
RemoveItembody that callsAddItem= infinite item glitch (yes, real)
TL;DR
Client = hostile. Validate every arg, whitelist every value, check every permission server-side, atomic money ops, rate limit, log critical actions, no secrets in client files.
If a resource skips ANY of these, it’s exploitable.
Sources
- FiveM Event Security Manual
- OWASP Top 10 - universal app sec principles
- OWASP Input Validation Cheat Sheet
- FiveM ACE Permissions
- GetConvar
- DropPlayer - kick natively
Next folder: 09-performance/ - start with 01-threads-and-waits.md