Why the server shouldn't wait on InvokeClient

RemoteFunction:InvokeClient lets a client stall or break server code. How to replace it with two RemoteEvents and a timeout the server controls.

warningLuaudit check: server-invokes-client

What it looks like

Server code calls RemoteFunction:InvokeClient and waits for the answer, often while holding a lock, a "busy" flag or a half-finished change.

Luau
local accepted = remotes.TradePrompt:InvokeClient(target, player)

How to detect it

  • Search server scripts and server-side modules for InvokeClient.
  • For each call, look at what the server has already changed before the call and what it undoes after. Anything set before and cleared after is stuck if the call never returns or throws.
  • Check whether the return value is trusted. It comes from the client and needs the same checks as any remote argument.
  • OnServerInvoke is the other direction, client asking server, and is not this check.

What an exploiter could do

The client decides when, and whether, InvokeClient returns. If it never answers, the server thread waits forever. If it throws an error, or the player leaves mid-call, InvokeClient errors on the server and the code after it never runs. Whatever was set before the call stays set: in a trade, both players can be locked out of trading until they rejoin. The answer, when it comes, can be any value.

How to fix it

  • Replace InvokeClient with two RemoteEvents: the server fires a request with FireClient, and the client answers on a second remote.
  • Keep pending requests on the server with the time they were sent. Accept an answer only from the player it was sent to, only while it's pending and only before a timeout.
  • Clear pending entries in Players.PlayerRemoving.
  • If you must keep InvokeClient, don't hold locks or half-made changes across it, wrap it in pcall, and validate what comes back.

Vulnerable example

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

local remotes = ReplicatedStorage.Remotes
local trading: { [Player]: boolean } = {}

remotes.RequestTrade.OnServerEvent:Connect(function(player: Player, target: unknown)
	if typeof(target) ~= "Instance" or not target:IsA("Player") or target == player then
		return
	end
	if trading[player] or trading[target] then
		return
	end
	trading[player] = true
	trading[target] = true
	local accepted = remotes.TradePrompt:InvokeClient(target, player)
	if accepted == true then
		remotes.TradeStarted:FireClient(player, target)
		remotes.TradeStarted:FireClient(target, player)
	end
	trading[player] = nil
	trading[target] = nil
end)

Fixed example

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

type Request = {
	from: Player,
	sentAt: number,
}

local remotes = ReplicatedStorage.Remotes
local timeout = 30
local pending: { [Player]: Request } = {}

local function activeRequest(player: Player): Request?
	local request = pending[player]
	if request and os.clock() - request.sentAt <= timeout and request.from.Parent == Players then
		return request
	end
	pending[player] = nil
	return nil
end

remotes.RequestTrade.OnServerEvent:Connect(function(player: Player, target: unknown)
	if typeof(target) ~= "Instance" or not target:IsA("Player") or target == player then
		return
	end
	if activeRequest(target) then
		return
	end
	pending[target] = { from = player, sentAt = os.clock() }
	remotes.TradeRequest:FireClient(target, player)
end)

remotes.AnswerTrade.OnServerEvent:Connect(function(player: Player, accepted: unknown)
	local request = activeRequest(player)
	if not request or typeof(accepted) ~= "boolean" then
		return
	end
	pending[player] = nil
	if accepted then
		remotes.TradeStarted:FireClient(request.from, player)
		remotes.TradeStarted:FireClient(player, request.from)
	end
end)

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

Common false positives

  • OnServerInvoke handlers, where the client calls the server, don't wait on a client. Check them against the other remote checks instead.
  • InvokeClient wrapped in pcall, run in a thread nothing else waits on (its own task.spawn, or an event handler such as PlayerAdded), holding no locks or partial changes, and with its result validated, has dealt with the risk. Mention it in "What you did well".