Why admin checks should use UserId, not names

Display names aren't unique and usernames can change or be matched by prefix. How to recognise staff by UserId or group role instead.

criticalLuaudit check: name-based-admins

What it looks like

The server decides who is staff from a player's name: their DisplayName, their username, part of their username, or their character's name. The check runs on the server, but the thing it checks isn't tied to one account.

Luau
local admins = { "BlueOakBuilds", "Mira" }

if table.find(admins, player.DisplayName) then
	runCommand(player, message)
end

How to detect it

  • Find every permission check for commands, admin remotes, staff panels, debug tools and staff-only areas, and see what it compares.
  • A check on DisplayName is a finding. Display names aren't unique, so anyone can set theirs to match a staff member's.
  • A partial match on a username is a finding: string.find, string.match, string.sub prefixes, or a shared studio tag such as names starting with BlueOak. Anyone can make an account whose username contains that text.
  • An exact match on Name is a finding too. Roblox's reference says UserId uniquely identifies an account and never changes, while players can change their username. When a staff member changes theirs, the list no longer matches them.
  • A check on the character's name or Humanoid.DisplayName is matching a name too. Roblox sets Humanoid.DisplayName from Player.DisplayName.
  • Hardcoded names given to accounts outside the team are Powers given to accounts outside the team in backdoors. This check is about the game's own staff list being matched by name.

What an exploiter could do

With a display name check, anyone can set their display name to a staff member's and get every command: kicking and banning players, giving items and currency, or shutting down servers. With a partial match, they make an account whose username contains the text the check looks for.

How to fix it

  • Keep staff as a list of UserIds, or check group roles on the server with GroupService:GetRolesInGroupAsync.
  • Use names only to show who someone is, never to decide what they can do.

Vulnerable example

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

local staff = { "BlueOakBuilds", "MiraScripts" }

ReplicatedStorage.Remotes.ModKick.OnServerEvent:Connect(function(player: Player, target: unknown)
	if not table.find(staff, player.DisplayName) then
		return
	end
	if typeof(target) ~= "Instance" or not target:IsA("Player") or target == player then
		return
	end
	if target.Parent == Players then
		target:Kick("Removed by a moderator.")
	end
end)

Fixed example

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

local staff: { [number]: boolean } = {
	[123456789] = true,
	[987654321] = true,
}

ReplicatedStorage.Remotes.ModKick.OnServerEvent:Connect(function(player: Player, target: unknown)
	if not staff[player.UserId] then
		return
	end
	if typeof(target) ~= "Instance" or not target:IsA("Player") or target == player then
		return
	end
	if target.Parent == Players then
		target:Kick("Removed by a moderator.")
	end
end)

Common false positives

  • Names used only for display, such as a "made by" sign, a join message or a credits board, don't decide anything.
  • Finding a target by name for a command, once the caller has passed a UserId check, is judged by Command targets matched too loosely, not this check.
  • Comparing UserId with game.CreatorId for a game owned by a user, or checking group roles on the server, is the fix.