Multi-signal ban system for FiveM. Two kinds of ban:
- Full ban — the player cannot connect.
- Function ban — the player keeps playing, but specific capabilities are locked (shooting, driving, voice, chat, jumping, sprinting, and more).
Matching is done on a primary identifier + hardware tokens, so a fresh account on the same machine still gets caught and logged as evasion.
ox_liboxmysql- OneSync
- Drop the folder in your resources and
ensure bansystem. - The schema is created automatically on first start.
DataBase.sqlis there if you prefer to import it manually. - Grant permissions in
server.cfg:
add_ace group.admin bansystem.ban allow
add_ace group.admin bansystem.unban allow
add_ace group.admin bansystem.permanent allow
add_ace group.admin bansystem.immune allow
add_ace group.mod bansystem.fban allow
add_ace group.mod bansystem.view allow
add_ace group.mod bansystem.unban allowEvery permission lives under the bansystem object, so a group can be granted the whole set at once and denied individual leaves. The most specific rule always wins, which is the short way to say "everything except immunity":
add_ace group.admin bansystem allow
add_ace group.admin bansystem.immune denyEverything else is tuned in shared/config.lua.
A ban is keyed on one identifier. Which one is your choice:
Config.Identity = {
primary = 'license', -- license, license2, steam, discord, fivem, xbl, live, ip
fallback = 'license', -- used when the primary is missing; false rejects the connection
label = 'Rockstar license', -- shown to players in error messages
bare = { pattern = '^%x+$', length = 40 },
}bare is what lets an admin type /ban 4f2a9c... 7d instead of the full license:4f2a9c.... Change it to match whatever primary is:
| primary | bare |
|---|---|
license, license2 |
{ pattern = '^%x+$', length = 40 } |
discord, steam, fivem, xbl, live |
{ pattern = '^%d+$', length = false } |
| anything | false to require the prefixed form |
Only license is guaranteed to exist for every player — Steam is missing for non-Steam launches, Discord is missing when Discord isn't running. That is what fallback is for: set it and those players stay bannable, or set it to false to refuse the connection outright. Everything listed in storeExtra is recorded alongside for reference, so you can still see a player's Steam and Discord ids regardless of which one is primary.
ip is accepted but a bad idea — addresses are shared and reassigned, so you get false positives and trivial evasion. The resource prints a warning at boot if you pick it.
Switching primary on a live server does not rewrite existing rows: old bans stay keyed on the old identifier and keep working via token matching, but their primary-identifier match no longer applies. Change it before you go live, or clear bansystem_bans afterwards.
| Command | Example |
|---|---|
/ban <target> <duration> [reason] |
/ban 12 7d cheating |
/fban <target> <functions> <duration> [reason] |
/fban 12 shoot,drive 3d rdm |
/unban <code|id> [reason] |
/unban BAN-7KQ2XF appeal accepted |
/unfban <target> [functions] [reason] |
/unfban 12 voice |
/checkban <target> |
/checkban 12 |
/baninfo <code|id> |
/baninfo BAN-7KQ2XF |
/bans [page] [search] |
/bans 2 rdm |
/banpanel |
opens the admin panel |
/banfunctions |
lists every function and group |
/banreasons |
lists every reason preset |
/banimport <file> [apply] |
/banimport import/playersDB.json apply |
<target> accepts a server id, the primary identifier (prefixed or bare), or a stored player name.
Durations: 30m, 6h, 3d, 2w, 1mo, 1d12h, or perm. A bare number means minutes.
Functions: any key from Config.Functions, any group from Config.FunctionGroups (combat, vehicle, communication, movement, misc), or all. Comma separated.
Drop a backup in the resource's import/ folder and run the import from the server console. The format is worked out from the file, so the command is the same either way.
banimport import/playersDB.json # dry run - reads the file, writes nothing
banimport import/playersDB.json apply # writes
banimport import/dump.sql apply hrban # third argument forces a format
Two sources are understood today:
| Format | File | Where it comes from |
|---|---|---|
txadmin |
playersDB_<date>.json |
txAdmin → Settings → Backup |
hrban |
a .sql dump |
phpMyAdmin / mysqldump of an hr_bansystem table |
The dry run is the whole point: it prints exactly what would happen, so you can check the numbers before anything touches the database. Run it first, every time.
[DRY RUN - nothing written] import/playersDB.json
source : txAdmin playersDB
players : 39 read | 39 new | 0 updated | 0 without a license | 280 tokens
bans : 1 read | 1 imported (1 active) | 0 already imported | 0 skipped | 0 failed | 12 tokens
What maps to what
| txAdmin | here |
|---|---|
players[] |
bansystem_players — name, hardware tokens, every identifier, original join and last-seen dates |
actions[] with type: "ban" |
bansystem_bans as a full ban |
hwids on an action |
bansystem_ban_tokens — this is what catches the alt account |
expiration: false |
permanent |
expiration: <past> |
imported as inactive history, revoke_reason = 'expired before import' |
revocation.timestamp |
imported as inactive, revoker and date preserved |
actions[] with type: "warn" |
counted in the report, not imported — there is no warning concept here |
| hr_bansystem | here |
|---|---|
Steam License IP Discord Xbox Live |
identifiers, keyed on whichever one Config.Identity.primary names — accepted bare or already prefixed |
Tokens |
tokens on both the identity row and the ban — a JSON array, or a comma separated list |
isBanned = 1 |
a full ban |
isBanned = 0 |
identity row only, no ban — those rows still carry tokens worth keeping |
Expire = 0 |
permanent |
Expire in the past |
imported as inactive history |
Reason |
the ban reason, verbatim |
Every imported ban keeps its original reason text and, where the source has one, its author and date. reason_key is left empty, because neither system has reason presets to map onto Config.Reasons — escalation therefore starts counting from this system's own bans, not the imported ones. Function bans are never invented: both sources only have one kind of punishment, and guessing which capability an old free-text reason should lock would be worse than not guessing.
Re-running is safe. Every imported row carries a [<format>:<source id>] marker in its note, so a second run reports already imported instead of duplicating. Player rows are merged rather than replaced: tokens are unioned with what's already stored, the earliest first_seen wins, and a source with no names of its own will never overwrite a name an earlier import established. Importing txAdmin first and hr_bansystem second is a reasonable thing to do — the second pass borrows names from the first.
Things worth checking before you apply
- Records skipped for having no primary identifier. A row is only importable if it carries whatever
Config.Identity.primaryis set to. A Steam-only ban is unimportable on alicense-keyed server; the report says so underskipped. - The bare-identifier warning. If the report says your license values don't match
Config.Identity.bare,/ban <value>won't resolve them and admins will have to type the prefixedlicense:...form. Fix the pattern in the config to match your server's format. hr_bansystemhas no issue date. The schema stores onlyExpire, so imported bans are dated at the time of import and say so in their note. Nothing is fabricated to fill the gap.
Anyone online who matches a freshly imported full ban is kicked at the end of the run, and the live cache is rebuilt — no restart needed.
Tuning lives in Config.Import: keepCodes, expiredAs, importRevoked, importPlayers, and hrban.table if your table was renamed. The command is gated behind bansystem.import:
add_ace group.admin bansystem.import allow
Adding a third system. server/import.lua reduces every source to one intermediate shape before anything is written, so the rules that matter — which identifier a ban is keyed on, what counts as a duplicate, how tokens merge — are written once and apply to every format. A new source is a detect and a parse function appended to Import.Formats; nothing below that needs to know it exists.
| Key | Effect |
|---|---|
shoot |
firing blocked client-side, weaponDamageEvent and explosions cancelled server-side |
melee |
melee controls blocked, melee damage cancelled server-side |
weapon_pickup |
weapon wheel locked, weapons stripped on apply |
drive |
moved to a passenger seat or ejected from the driver seat |
enter_vehicle |
cannot enter any vehicle |
voice |
voice deactivated and volume overridden to zero |
chat |
chat key disabled, chatMessage cancelled server-side |
jump |
jump control disabled |
sprint |
sprint disabled and move-blend capped to walking |
emote |
flag only — read it from your emote resource |
trade |
flag only — read it from your shop resource |
Every hard-enforced function is blocked on both sides: the client blocks the input, the server cancels the outcome. A modified client that ignores the control locks still cannot land damage or send chat.
drive and enter_vehicle are the exception, and worth understanding. The natives that pull a ped out of a seat — TaskLeaveVehicle, SetPedIntoVehicle, ClearPedTasks — only exist client-side. The server has no way to undo the action, so it doesn't pretend to. See below.
A player still sitting in a seat they're banned from, sweep after sweep, is running a client that ignored the lock. The server can't eject them, but it can notice — and a client that ignores a control lock is a modified client, not a driving problem.
Config.Tamper turns that observation into an escalation:
Config.Tamper = {
interval = 5, -- seconds between sweeps
grace = 2, -- strikes forgiven (exit animations, desync)
kickAt = 4, -- strikes before a kick
banAt = 6, -- strikes before an automatic full ban; false to never
decay = 60, -- seconds of clean play that clears one strike
forget = '2h', -- untouched records are dropped
}With the defaults: nothing happens for the first 10 seconds of violation, a kick lands at 20 seconds, and an automatic permanent ban at 30. Strikes are kept against the identifier, not the session — a kick ends the session, so session-scoped strikes would reset on every reconnect and banAt could never be reached. decay and forget make sure a player who desyncs once never accumulates their way into a ban.
Set enabled = false to disable it entirely and rely on client enforcement alone.
Config.Reasons holds presets. Each preset carries its own type, default duration, and (for function bans) the function list — so /fban 12 shoot 0 rdm is usually unnecessary; /ban 12 0 rdm picks up the preset's own settings. Free-text reasons still work when Config.AllowCustomReason is on.
Config.Escalation multiplies the duration by how many prior bans the player has for the same reason key, and flips to permanent after permanentAfter offences.
/banpanel opens an in-game NUI panel. It is a view over the same code path the commands use —
issuing a ban from the panel runs Bans.Create exactly as /ban does, so escalation, the overlap
check, the kick and the statebag push all behave identically.
| Tab | What it is for |
|---|---|
| Overview | active/permanent counts, a 14-day trend, top reasons and admins, evasion and tamper counts, recent activity |
| Bans | every ban with search, type and status filters; open one for its full record and token snapshot |
| New ban | full or function ban, reason presets that fill in their own duration and function list, evidence and staff notes |
| Players | who is connected, what is locked for them, and one-click ban or function ban |
| Profile | a player's identifiers, hardware tokens, ban history, live locks, and other accounts on the same machine |
| Audit trail | the whole bansystem_logs table, filterable by action |
The panel is not a second permission system. /banpanel is registered with bansystem.view, and
every action the panel performs re-checks its own ace on the server: issuing a full ban needs
bansystem.ban, a function ban needs bansystem.fban, revoking needs bansystem.unban, and the
maxDurationWithoutPerm cap still applies to admins without bansystem.permanent. Buttons an
admin cannot use are hidden, but hiding is only a courtesy — the server refuses the call either
way, including when it arrives from a client poking at the callbacks directly.
The compiled UI lives in web/build, which is not in git — it ships with the release archive.
If you cloned the repo instead of downloading a release, build it once before starting the resource,
and rebuild after editing anything under web/src:
cd web
npm install
npm run buildnpm run dev serves the panel in a normal browser against fixtures, which is the fast way to work
on it. See web/README.md.
Server:
if exports.bansystem:isFunctionBanned(source, 'trade') then return end
exports.bansystem:functionBan(source, 'voice', {
reason = 'mic spam',
duration = '2h',
adminName = 'anticheat',
})
exports.bansystem:banPlayer(source, { reasonKey = 'cheating' })
exports.bansystem:revokeBan('BAN-7KQ2XF', 'admin', 'appeal accepted')Client:
if exports.bansystem:isFunctionBanned('emote') then
return lib.notify({ type = 'error', description = 'Emotes are locked for you.' })
endStatebag (either side, no event needed):
local locked = Player(source).state.banFunctions -- server
local locked = LocalPlayer.state.banFunctions -- clientshared/config.lua all tunables
shared/utils.lua duration parsing, function resolution, formatting
server/identity.lua resolves who a player is (primary identifier + tokens)
server/database.lua the only file that writes SQL
server/bans.lua cache, identity matching, create/revoke, expiry
server/targets.lua target resolution and the admin guards, shared by commands and panel
server/main.lua connection gate, server-side enforcement, exports
server/commands.lua admin commands
server/sqldump.lua reads rows back out of a mysqldump, for the importer
server/import.lua importer for txAdmin and hr_bansystem backups
server/panel.lua the panel's callbacks - ace checked, then straight into server/bans.lua
client/main.lua per-frame enforcement
client/panel.lua NUI bridge, nothing but the window
locales/ en, fa
web/ panel source (React + TypeScript)
web/build/ compiled panel, not in git - built locally or taken from a release
Copyright (c) 2026 Ilia
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
Full text: COPYING.LESSER (LGPL-3.0) and COPYING (GPL-3.0, which the LGPL incorporates by reference).