The permission system behind a live game, built on the assumption that every request might be a forgery.
"The client is untrusted territory. It REQUESTS; the server AUTHORIZES and ACTS. A client-side permission check is UX, never security."
The trust boundary
When a remote fires, the engine hands the server the real player as an argument that can't be spoofed. Everything else in the payload is attacker-controlled. So the rule is exact: trust the identity, validate everything else.
untrusted · player's machine
client fires a request
Remote:FireServer(
"kick", target
)
any action, any payload: all attacker-controlled
trust boundary
server · authoritative
1engine injects the real Player: unspoofable identity
2validateShape(req): is this even a possible request?
3can(actor, capability): the actual allow / deny
4perform the action: only if it returned true
Two axes, not one ladder
Most permission bugs come from modelling privilege as a single number. This system keeps paid perks and staff powers on separate axes and unions them per player.
perk tier · paid, inherits upward
user→
vip→
vip+→
mvp→
mvp+
staff track · separate, never purchasable
mod→
admin→
owner
A real player carries one perk tier and, optionally, one staff role. The resolver unions both; a moderator isn't a superset of mvp+, so privilege is a set of named capabilities, never a single number.
In the code
The gateway: deny vs. impossible
Every privileged request goes through one function. First it checks whether the payload is even shaped like something a real client could send. A well-formed request that's denied is a WARN. Your own UI or a lagged replay can cause it. A structurally impossible payload is an ALERT: no legitimate client produces it, so it's high-confidence forgery. And notice it only logs and flags. It never bans. The deny already won; auto-banning would false-positive on real players.
PermissionService.lua · authorize()
-- Is the request even SHAPED like something a real client sends?-- A false here means "no legitimate client produced this".local function validateShape(req: AuthRequest): (boolean, string?) if typeof(req.actor) ~= "Instance" or not req.actor:IsA("Player") then return false, "actor is not a Player" end if type(req.capability) ~= "string" then return false, "capability is not a string" end if req.requiresTarget then if typeof(req.target) ~= "Instance" or not req.target:IsA("Player") then return false, "action requires a Player target but none was given" end end return true, nilendfunction PermissionService.authorize(req: AuthRequest): boolean -- Identity comes from the ENGINE-PROVIDED actor only, never the payload. -- 1) Shape validation -- forgery detection. local shapeOk, reason = validateShape(req) if not shapeOk then forgeryFlags[actorId] = (forgeryFlags[actorId] or 0) + 1 AuditLog.record({ severity = AuditLog.Severity.Alert, event = "forged_payload", -- + actorId, capability, and a "forgery flag #N" detail }) return false end -- 2) The actual permission check. local allowed = PermissionService.can(req.actor, req.capability) -- 3) Permitted -> INFO; denied-but-well-formed -> WARN (never punished). AuditLog.record({ severity = allowed and AuditLog.Severity.Info or AuditLog.Severity.Warn, event = allowed and "authorized" or "denied", }) return allowedend
Inheritance, resolved once and fail-closed
A role's capabilities are the union of everything up its inheritance chain. It's resolved once and cached, guarded against cycles, and an unknown role returns nil, which means you get nothing. The safe default is no permissions, never a crash and never an accidental grant.
PermissionService.lua · resolveRole()
-- Unions capabilities up the inheritsFrom chain. Resolved once and cached;-- cycle-guarded; unknown role -> nil (you get nothing -- fail closed).local function resolveRole(roleName: string): ResolvedRole? local cached = resolvedCache[roleName] if cached then return cached end local def = Roles.Definitions[roleName] if not def then return nil -- unknown role grants nothing end if resolving[roleName] then warn("cyclic inheritance at role '" .. roleName .. "'") return nil end resolving[roleName] = true local capabilities, perks = {}, {} if def.inheritsFrom then local parent = resolveRole(def.inheritsFrom) if parent then for cap in parent.capabilities do capabilities[cap] = true end for k, v in parent.perks do perks[k] = v end end end for cap, granted in def.capabilities do if granted then capabilities[cap] = true end end for k, v in def.perks do perks[k] = v end resolving[roleName] = nil resolvedCache[roleName] = { capabilities = capabilities, perks = perks } return resolvedCache[roleName]end
A purchase is a claim until the server confirms it
The client never tells the server what it bought. The server asks the marketplace directly, and every check is wrapped so it fails closed: a dropped network call leaves the player where they were instead of wrongly upgrading them. A purchase can raise your tier; it can never lower it.
PermissionService.lua · syncPurchasedTier()
-- A purchase is a CLAIM until the server confirms it. We never trust a client-- saying "I bought MVP" -- we ask MarketplaceService, server-side.-- Each check is pcall'd and fails CLOSED: a network error upgrades no one.function PermissionService.syncPurchasedTier(player: Player) for passId, tierName in Roles.GamePassToTier do local ok, owns = pcall(function() return MarketplaceService:UserOwnsGamePassAsync(player.UserId, passId) end) if ok and owns then -- upgradeTierIfHigher: a purchase can never DOWNGRADE a player upgradeTierIfHigher(player, tierName) end endend
A sink you can repoint without touching a caller
Every decision flows through one entry point. Today it prints structured JSON you can grep for ALERT and persists each record under a unique key. Shipping logs to a real external database later is a single HTTP POST added here; nothing that calls record() ever has to know the destination changed.
AuditLog.lua · record()
-- One record() entry point. The destination can change with no caller knowing.function AuditLog.record(entry: LogEntry) entry.timestamp = os.time() entry.serverJobId = game.JobId -- 1) Structured stdout line -- the parseable surface (grep for "ALERT"). local ok, encoded = pcall(function() return HttpService:JSONEncode(entry) end) if ok then print("[AUDIT] " .. encoded) end -- 2) Persist under a unique key, fire-and-forget so it never blocks. if store then local key = nextKey() task.spawn(function() pcall(function() store:SetAsync(key, entry) end) end) end -- 3) (Future) external DB: a single pcall'd HttpService:PostAsync here is -- the ONLY change needed. Nothing else would know.end
the restraint
The gateway logs and flags, but it never bans. The security goal, the action not running, is already met by the deny. Banning is a separate decision for a human or a higher-confidence pipeline. Auto-banning on a denied check would false-positive on my own players and my own testing.