How to limit Roblox admin commands by staff rank

When every staff member can run every command, a trial moderator can ban the owner. How to give each command a level and stop staff acting on higher ranks.

criticalLuaudit check: command-rank-checks

What it looks like

The game has more than one staff rank, such as moderator, admin and owner, but the server only asks whether the caller is staff. Every rank can run every command. Or each command has a rank, but nothing compares the caller with the player they're acting on, so a moderator can ban an admin or the owner.

Luau
if staff[player.UserId] then
	commands[name](player, target)
end

How to detect it

  • Find where the game decides who is staff: a UserId table, a group rank, a data store list or a command module's settings. Note how many levels it has. If several scripts keep their own staff tables, compare them: a level set in one script means little if another script treats the same accounts as equals.
  • For each command, find the level it needs and check that the server compares the caller's level with it before running anything. A rank shown in the admin panel, or commands hidden from lower ranks in the UI, doesn't count.
  • For every command that acts on another player, such as kick, ban, mute, jail, teleport, or changing their data or rank, check that it refuses targets at the caller's level or above.
  • Commands that change ranks need both checks: the caller can't give a rank at or above their own, and can't change the rank of someone at or above theirs.
  • Look for server-wide commands, like shutdown, global announcements, giving currency or wiping data, that are open to the lowest rank.

What an exploiter could do

Anyone with the lowest staff rank, including a trial moderator or someone using a stolen moderator account, can use the strongest commands: give themselves currency, shut servers down, or ban the owner and every other member of staff so nobody can undo it.

How to fix it

  • Give every command a minimum level, kept on the server next to the command itself.
  • Look up the caller's level on the server and refuse the command if it's below the command's level.
  • For commands on another player, refuse when the target's level is the same as or higher than the caller's.
  • Keep one table of commands and one place that checks it, so a new command can't skip the check.

Vulnerable example

Vulnerable
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local levels: { [number]: number } = {
	[123456789] = 3,
	[987654321] = 1,
}

local commands: { [string]: (Player) -> () } = {
	kick = function(target)
		target:Kick("Removed by staff.")
	end,
	ban = function(target)
		local ok, err = pcall(Players.BanAsync, Players, {
			UserIds = { target.UserId },
			Duration = -1,
			DisplayReason = "Banned by staff.",
			PrivateReason = "Staff command",
		})
		if not ok then
			warn(err)
		end
	end,
}

ReplicatedStorage.Remotes.StaffCommand.OnServerEvent:Connect(function(player: Player, name: unknown, target: unknown)
	if not levels[player.UserId] then
		return
	end
	if typeof(name) ~= "string" or typeof(target) ~= "Instance" or not target:IsA("Player") then
		return
	end
	local command = commands[name]
	if command then
		command(target)
	end
end)

Fixed example

Fixed
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

type Command = {
	level: number,
	run: (Player) -> (),
}

local levels: { [number]: number } = {
	[123456789] = 3,
	[987654321] = 1,
}

local commands: { [string]: Command } = {
	kick = {
		level = 1,
		run = function(target)
			target:Kick("Removed by staff.")
		end,
	},
	ban = {
		level = 2,
		run = function(target)
			local ok, err = pcall(Players.BanAsync, Players, {
				UserIds = { target.UserId },
				Duration = -1,
				DisplayReason = "Banned by staff.",
				PrivateReason = "Staff command",
			})
			if not ok then
				warn(err)
			end
		end,
	},
}

ReplicatedStorage.Remotes.StaffCommand.OnServerEvent:Connect(function(player: Player, name: unknown, target: unknown)
	local level = levels[player.UserId] or 0
	if typeof(name) ~= "string" or typeof(target) ~= "Instance" or not target:IsA("Player") then
		return
	end
	local command = commands[name]
	if not command or level < command.level then
		return
	end
	if (levels[target.UserId] or 0) >= level then
		return
	end
	command.run(target)
end)

Common false positives

  • Games with a single staff level, where everyone on the list is trusted equally, don't need per-command levels. Mention it only if the list mixes trusted and trial staff.
  • Well-known admin modules check ranks inside the module. Check the ranks given in its settings rather than reporting the module itself.
  • Commands that only affect the caller, like flying or going invisible to test a map, don't need a target check.