How to relay client messages to every player safely

Passing what one client sends straight to FireAllClients multiplies every call by the player count. How to rate-limit, rebuild and batch relayed messages.

warningLuaudit check: relayed-to-all-clients

What it looks like

A handler takes what one client sends and passes it on to every client with FireAllClients, or to each player in a loop. Typing indicators, look direction, drawings, sprays and effects often work like this. Each call from one client becomes one message to every player in the server, often with the client's own value passed straight through.

Luau
remote.OnServerEvent:Connect(function(player, direction)
	remote:FireAllClients(player, direction)
end)

How to detect it

  • Find OnServerEvent handlers that call FireAllClients, or FireClient in a loop over Players:GetPlayers().
  • Check for a per-player rate limit before the relay. One client can send roughly 500 remote events a second, and the server multiplies each one by the number of players.
  • Check what's relayed. Values passed on unchanged, especially tables and strings, reach every other client's code with whatever size and contents the sender chose. The server should rebuild the message from values it has checked.
  • Frequent cosmetic updates sent the moment they arrive are better batched: keep the latest value per player and send them together a few times a second.

What an exploiter could do

They can flood every other player with messages, which lags their games, and make every client run its handler for values it never expected. If the relayed value is a table or a long string, one call puts that whole payload on the network once for each player in the server.

How to fix it

  • Rate-limit each player before relaying, or keep only their latest value and send it on a fixed timer.
  • Validate the client's value and send a new one built on the server, never the client's table or string as it arrived.
  • Cap lengths and sizes before anything is relayed.

Vulnerable example

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

local look = ReplicatedStorage.Remotes.LookDirection

look.OnServerEvent:Connect(function(player: Player, direction: unknown)
	if typeof(direction) ~= "Vector3" then
		return
	end
	look:FireAllClients(player, direction)
end)

Fixed example

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

local look = ReplicatedStorage.Remotes.LookDirection
local latest: { [Player]: Vector3 } = {}

look.OnServerEvent:Connect(function(player: Player, direction: unknown)
	if typeof(direction) ~= "Vector3" then
		return
	end
	local size = direction.Magnitude
	if not (size > 0 and size < math.huge) then
		return
	end
	latest[player] = direction / size
end)

Players.PlayerRemoving:Connect(function(player)
	latest[player] = nil
end)

while true do
	task.wait(0.1)
	local players: { Player } = {}
	local directions: { Vector3 } = {}
	for player, direction in latest do
		table.insert(players, player)
		table.insert(directions, direction)
	end
	table.clear(latest)
	if #players > 0 then
		look:FireAllClients(players, directions)
	end
end

Common false positives

  • Relays with a per-player cooldown that only send values rebuilt on the server are the fix.
  • Messages the server sends on its own schedule, like round timers, aren't relays.
  • UnreliableRemoteEvent drops messages over 1,000 bytes, so it limits payload size. It doesn't limit how often a client fires, so the rate limit is still needed.