Most "my script is broken" messages come down to two things: a misplaced comma in a config table, or a permission check that lives on the client where any cheater can delete it. This lesson fixes both. You'll learn to write config tables that don't bite, gate features with ACE permissions, and put the real security check where it counts — the server.
Config tables: the dial panel, not the engine
A config table is a plain Lua table you keep in its own file, separate from your logic. The point is leverage: a server owner who runs your script should be able to change how much money a job pays or where a garage spawns without ever opening the code that does the work. You change dials, not wiring.
The pattern is always the same — declare the table, then hang values off it:
-- config.lua
Config = {}
Config.MaxStored = 3 -- a number: no quotes
Config.JobName = "mechanic" -- a string: quotes required
Config.AdminAce = "playdeck.garage.admin" -- the ACE we check later
Config.Locations = { -- a list of tables (array-style)
{ label = "Legion", coords = vec3(215.0, -810.0, 30.7) },
{ label = "Sandy", coords = vec3(1834.0, 3686.0, 34.2) },
}
Config is global so other files in the same resource can read it. Nothing here talks to the game yet — it's just data. That separation is the whole reason config files exist.
The comma is load-bearing
Here is the single most common syntax error in FiveM scripting, and it has nothing to do with FiveM: every entry in a Lua table is separated by a comma. Lua tables aren't whitespace-aware like Python or YAML. Put two entries on two lines with no comma between them and Lua doesn't see "two items" — it sees a broken expression and your whole resource fails to start.
Config.Locations = {
{ label = "Legion", coords = vec3(215.0, -810.0, 30.7) } -- no comma...
{ label = "Sandy", coords = vec3(1834.0, 3686.0, 34.2) } -- ...syntax error
}
Two things that surprise beginners, both true and both useful:
- A trailing comma after the last item is legal in Lua.
{ a, b, }is fine. So get in the habit of ending every line with a comma — then adding a new entry never breaks the one above it. - Inside
{ key = value }you can separate fields with either commas or semicolons, but pick one and stay consistent. Mixed punctuation is how you lose ten minutes squinting at a line that "looks right."
When a resource won't start, read the server console. Lua tells you the file and line of a syntax error. The line it names is usually the one after the missing comma — so check the line above the one it points at first.
ACE permissions and principals
Now, who is allowed to use the admin features? FiveM's built-in answer is the ACE system — Access Control Entries. Two words to anchor:
- A principal is an identity or a group: a specific player (
identifier.fivem:1234567), or a role likegroup.admin. - An ACE object is a named permission you invent, like
playdeck.garage.admin. You decide what it protects.
You wire them together in server.cfg:
# server.cfg — define WHO can do WHAT
add_ace group.admin playdeck.garage.admin allow # the role gets the permission
add_principal identifier.fivem:1234567 group.admin # this player IS an admin
Read top-down: the admin group is allowed the garage permission, and player 1234567 is in the admin group. Change staff by editing principals — you never touch the script. That's the same leverage idea as config, applied to people.
You don't have to memorize the exact directive names. Keep the official Cfx ACL/principals page open and link to it from your script's README — the syntax is stable but it's the kind of thing worth confirming against the real reference, not from memory.
Put the check on the server (the part AI gets wrong)
This is the lesson inside the lesson. When you ask an AI to "add an admin menu," it will very often check the permission on the client — and a client check protects nothing. The client is the player's own machine. A cheater can edit it, skip it, or fake the answer. If the only gate is client-side, you don't have a gate.
The fix: the client may ask, but the server decides. With ox_lib, you do this through lib.callback — a typed request/response between client and server. The server runs IsPlayerAceAllowed, and only sends data back if the check passes.
-- server.lua
lib.callback.register("playdeck:garage:openAdmin", function(source)
-- `source` is the player's server id, set by the server itself — the client can't spoof it.
if not IsPlayerAceAllowed(source, Config.AdminAce) then
return false -- deny: no menu, and no garage data ever leaves the server
end
return { garages = GetAllGarages() } -- replace with your real data source
end)
-- client.lua
local data = lib.callback.await("playdeck:garage:openAdmin", false)
if not data then return end -- denied: there is simply nothing to open
-- safe to build the menu now; the sensitive data only exists because the server allowed it
Notice the menu can't even render for an unauthorized player, because the data they'd need never arrives. That's the difference between hiding a button and actually securing a feature.
Two habits that keep AI from steering you wrong here: pin one framework (decide Qbox/QBCore or ESX up front — IsPlayerAceAllowed is a real native, but AI loves to invent fake exports and blend frameworks), and verify every native against the real reference and the PlayDeck sandbox before trusting it. If you can't find a native in the official docs, it doesn't exist.
Practice
In the PlayDeck browser sandbox — no FiveM needed — paste this and run it. We've mocked the permission check so you can feel the logic:
local Config = { AdminAce = "playdeck.garage.admin" }
local allowed = { ["alice"] = true, ["bob"] = false } -- pretend ACL
-- stand-in for the real server native
local function IsPlayerAceAllowed(player, ace)
return allowed[player] == true
end
local function openAdmin(player)
if not IsPlayerAceAllowed(player, Config.AdminAce) then
return "DENIED"
end
return "OK — sending garage data"
end
print("alice:", openAdmin("alice")) -- OK
print("bob: ", openAdmin("bob")) -- DENIED
Now break it on purpose: delete a comma inside the allowed table and watch the sandbox report the syntax error and the line. Then add a third player, "carol", set them to true, and confirm they pass. You just exercised both halves of this lesson — comma discipline and a server-side gate — without a server.
Recap
- A config table is data separated from logic so behavior can change without code edits. Numbers go bare, strings get quotes.
- Commas separate every table entry. A trailing comma is legal and worth keeping; a missing one is the #1 syntax error. The error line is usually below the real mistake.
- ACE permissions pair a principal (who) with an ACE object (what), wired in
server.cfg. Manage staff by editing principals, not the script. - Check permissions on the server. Use
lib.callbackso the client asks and the server — runningIsPlayerAceAllowed— decides. A client-side check protects nothing. - AI-done-right: pin one framework, verify every native against the official reference, and never trust the client. Link the real Cfx docs from your README rather than trusting memory.