How to secure admin chat commands in Roblox

A hidden TextChatCommand alias isn't a permission check. How to check the speaker's UserId or group role on the server before a command runs.

criticalLuaudit check: unguarded-chat-commands

What it looks like

A staff or developer command is run from chat, through TextChatCommand.Triggered or Player.Chatted, and the server handler never checks who sent it. The command is often hidden from autocomplete or has an unusual alias, as if nobody else would think to type it.

Luau
local giveCommand = TextChatService:WaitForChild("GiveCoins") :: TextChatCommand

giveCommand.Triggered:Connect(function(textSource, message)
	giveCoins(textSource.UserId, tonumber(string.split(message, " ")[2]))
end)

How to detect it

  • Find every TextChatCommand in TextChatService, including ones created by scripts, and every Triggered connection. Find every Player.Chatted connection that parses commands.
  • Work out what each command does. Commands that only affect the speaker's own look, like a size or emote command, are fine. Commands that grant currency or items, change other players, kick, ban, teleport, change the round or touch saved data need a permission check.
  • In each Triggered handler, check that the speaker is looked up from textSource.UserId and compared with a server-side list of UserIds or a group role checked on the server, before anything happens.
  • A command handled only in a LocalScript, which then fires a remote, moves the check to that remote. Judge the remote with Admin powers not checked on the server.
  • AutocompleteVisible = false, a secret alias or a long password-like command isn't a permission check. Anyone who learns the alias, from a leaked place file, a video or by reading client scripts that mention it, can type it.

What an exploiter could do

They don't even need an exploit. Anyone who types the command gets what it does: unlimited currency or items, kicking or banning other players, teleporting everyone or ending rounds. Command names spread quickly once one player finds them.

How to fix it

  • At the top of every staff command's handler, find the player with Players:GetPlayerByUserId(textSource.UserId) and return unless their UserId is on the server's staff list or their group role passes a server check.
  • Keep staff-only commands in one script so none is missed.
  • Validate every argument parsed from the message, as Command numbers not range-checked and Command targets matched too loosely describe.

Vulnerable example

Vulnerable
local Players = game:GetService("Players")
local TextChatService = game:GetService("TextChatService")
local PlayerData = require(script.Parent.PlayerData)

local command = Instance.new("TextChatCommand")
command.Name = "GiveCoins"
command.PrimaryAlias = "/coins"
command.AutocompleteVisible = false
command.Parent = TextChatService

command.Triggered:Connect(function(textSource: TextSource, message: string)
	local amount = tonumber(string.match(message, "^/coins (%d+)$"))
	local player = Players:GetPlayerByUserId(textSource.UserId)
	local profile = player and PlayerData.get(player)
	if profile and amount and amount <= 100000 then
		profile.Coins += amount
	end
end)

Fixed example

Fixed
local Players = game:GetService("Players")
local TextChatService = game:GetService("TextChatService")
local PlayerData = require(script.Parent.PlayerData)

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

local command = Instance.new("TextChatCommand")
command.Name = "GiveCoins"
command.PrimaryAlias = "/coins"
command.AutocompleteVisible = false
command.Parent = TextChatService

command.Triggered:Connect(function(textSource: TextSource, message: string)
	if not staff[textSource.UserId] then
		return
	end
	local amount = tonumber(string.match(message, "^/coins (%d+)$"))
	local player = Players:GetPlayerByUserId(textSource.UserId)
	local profile = player and PlayerData.get(player)
	if profile and amount and amount <= 100000 then
		profile.Coins += amount
	end
end)

Common false positives

  • Commands everyone is meant to use, such as /help, /emote or changing their own settings, need no permission check.
  • Roblox's built-in commands, made when CreateDefaultCommands is on, are handled by Roblox.
  • A command that checks the speaker's UserId or group role on the server, like the fixed example, is fine even if the check sits in a helper function. Follow the call.