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.
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
DisplayNameis 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.subprefixes, or a shared studio tag such as names starting withBlueOak. Anyone can make an account whose username contains that text. - An exact match on
Nameis a finding too. Roblox's reference saysUserIduniquely 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.DisplayNameis matching a name too. Roblox setsHumanoid.DisplayNamefromPlayer.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 withGroupService: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
UserIdcheck, is judged by Command targets matched too loosely, not this check. - Comparing
UserIdwithgame.CreatorIdfor a game owned by a user, or checking group roles on the server, is the fix.