How to keep roles and answers hidden from exploiters

Anything sent to a client or left in ReplicatedStorage can be read. How to send each player only what they're allowed to know.

warningLuaudit check: secrets-sent-to-clients

What it looks like

The game depends on something players aren't meant to know yet: who the murderer is, where the treasure is buried, which door is safe, the answer to a quiz question. The server sends it to every client, or puts it somewhere that replicates, and trusts the client's UI to hide it.

Luau
remotes.RolesAssigned:FireAllClients(roles)

How to detect it

  • Find hidden state the game keeps from players: roles, answers, locations, upcoming loot or anything a round's outcome turns on.
  • Follow where it goes. Treat these as sent to the client: FireAllClients, FireClient to players who shouldn't know it, attributes or value objects on anything in Workspace, ReplicatedStorage or a player, and ModuleScripts or other Instances in ReplicatedStorage or ReplicatedFirst.
  • A client script that receives the full data and only shows part of it is still a finding. What reaches the client can be read on the client.
  • Check what's sent at the start of a round, not only the reveal at the end.

What an exploiter could do

Anything that reaches their client can be read, and Roblox's own security guidance says exploiters can decompile any replicated LocalScript or ModuleScript. They can see every role, dig straight to the treasure or answer every question correctly. It doesn't change saved data on its own, but it decides who wins and ruins the round for everyone else.

How to fix it

  • Keep hidden state in server-only places: script variables, ServerStorage or ServerScriptService.
  • Send each player only what they're allowed to know, with FireClient. A murderer can be told they're the murderer without anyone else being told.
  • Reveal answers and roles when they stop being secret, such as at the end of the round.
  • Check guesses on the server. The client sends its guess and the server says whether it was right.
  • For hints such as hot or cold, work the hint out on the server and send only the hint.

Vulnerable example

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

local remotes = ReplicatedStorage.Remotes

local function startRound()
	local players = Players:GetPlayers()
	if #players < 3 then
		return
	end
	local murderer = players[math.random(#players)]
	local roles: { [string]: string } = {}
	for _, player in players do
		roles[player.Name] = if player == murderer then "Murderer" else "Innocent"
	end
	remotes.RolesAssigned:FireAllClients(roles)
end

return startRound

Fixed example

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

local remotes = ReplicatedStorage.Remotes

local function startRound()
	local players = Players:GetPlayers()
	if #players < 3 then
		return
	end
	local murderer = players[math.random(#players)]
	for _, player in players do
		remotes.RoleAssigned:FireClient(player, if player == murderer then "Murderer" else "Innocent")
	end
end

return startRound

Common false positives

  • Information that's meant to be public, such as prices, level requirements or a scoreboard, is fine to replicate. Trusting it when the client sends it back is a different problem.
  • Data sent with FireClient only to the players who are allowed to know it is fine.
  • A secret revealed after it stops mattering, such as roles at the end of the round, is fine.
  • Server-only containers, ServerStorage and ServerScriptService, don't replicate.