Why the server shouldn't trust prices or damage from the client

When a remote sends a price, reward or damage value, the client decides it. How to send only the player's choice and look everything else up on the server.

criticalLuaudit check: client-trusted-values

What it looks like

The client sends a value the server should work out for itself: a price, a reward, damage, a cooldown, a score, a multiplier, or whether it "won". The server type-checks it, maybe range-checks it, then uses it.

Luau
remote.OnServerEvent:Connect(function(player, itemId, price)
	profile.Coins -= price
end)

How to detect it

  • For each client argument, ask whether the server already knows, or could work out, the right value. Prices, costs, rewards, damage, XP, durations and outcomes all belong to the server.
  • Look for arguments named or used like price, cost, amount, reward, damage, xp, score, won, success, multiplier or duration, and for values copied straight into currency, stats or inventory.
  • Hits, targets and positions that decide damage or rewards are covered by Hits the server doesn't check were possible and World interaction with no distance check in server validation. Report them there.
  • A range check doesn't fix this. If the client can pick any value in the allowed range, they'll pick the best one.

What an exploiter could do

They choose the value. They can buy items for nothing, give themselves any reward or bonus the range allows, deal maximum damage on every hit or claim to have finished something they never did.

How to fix it

  • Send only the player's choice, such as an item id, and look up everything else on the server.
  • Keep prices, rewards and stats in a table the server reads. A ModuleScript in ReplicatedStorage is fine if the client also needs it for display, as long as the server reads its own copy rather than a value the client sends.

Vulnerable example

Vulnerable
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Catalogue = require(ReplicatedStorage.Shared.Catalogue)
local PlayerData = require(script.Parent.PlayerData)

ReplicatedStorage.Remotes.BuyItem.OnServerEvent:Connect(function(player: Player, itemId: unknown, price: unknown)
	if typeof(itemId) ~= "string" or not Catalogue[itemId] then
		return
	end
	if typeof(price) ~= "number" or not (price >= 0 and price <= 1e6 and price % 1 == 0) then
		return
	end
	local profile = PlayerData.get(player)
	if not profile or profile.Coins < price then
		return
	end
	profile.Coins -= price
	table.insert(profile.Inventory, itemId)
end)

Fixed example

Fixed
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Catalogue = require(ReplicatedStorage.Shared.Catalogue)
local PlayerData = require(script.Parent.PlayerData)

ReplicatedStorage.Remotes.BuyItem.OnServerEvent:Connect(function(player: Player, itemId: unknown)
	if typeof(itemId) ~= "string" then
		return
	end
	local item = Catalogue[itemId]
	local profile = PlayerData.get(player)
	if not item or not profile or profile.Coins < item.price then
		return
	end
	profile.Coins -= item.price
	table.insert(profile.Inventory, itemId)
end)

Common false positives

  • Values that only affect the player's own experience and nothing others see or that persists, such as camera settings or UI preferences, can come from the client. They still need type and allowlist checks, and a missing one is an Unvalidated remote arguments finding.
  • A value the client sends only to pick between options the server then validates, such as an item id or a slot number, is a choice, not a trusted value.
  • Prices stored in ReplicatedStorage are not a problem on their own. The finding is the server taking the price from the client.