Validating what comes from the client: the NaN that walks through all your checks
Every piece of data the client sends has to be validated on the server. This guide walks the four layers the official documentation describes, with the case of NaN, which is of type number and still escapes any comparison.
Produced with AI from the sources cited below, and checked by automated verification against those same sources. No person read this text before publication. Editorial Policy

The sentence that holds up the whole security model of a Roblox game fits on one line, and the official documentation writes it without hedging: every piece of data sent by the client has to be validated by the server before it is used.
The problem is that "validate" sounds like one thing, and it is four — in layers, with an order between them. Skipping one of them produces no error; it produces a game that works perfectly until somebody finds the hole. This guide walks all four, and ends on the case that defeats the best-intentioned defences: NaN.
The problem
The door exists by design. Every time the client can trigger an action on the server there is potential for abuse — that is not an implementation flaw, it is the nature of having a client and a server.
And the door is wider than most people imagine. RemoteEvent and RemoteFunction are the most common vector, but the documentation reminds you that other instances, such as ProximityPrompt, are susceptible too. Anyone who armoured only the remotes armoured the part they were looking at.
The design that turns up most in a small game is the same everywhere: a folder in ReplicatedStorage with the shop items, each with a NumberValue price, and a remote that receives "the item the player wants to buy". It is convenient, it is readable — and it is exactly the shape the official example uses to demonstrate the attack.
The misunderstanding that holds all of this up is worth naming, because it is not stupidity: it is economy. The client already knows whether the player has the money, whether they are near the shop, whether the item exists. Writing the check there is natural, the button gets disabled, the game behaves correctly, and the screen never shows an invalid state. The problem is that none of that is a lock — it is an interface convenience. Client code runs on the machine of the person playing, and the person playing can swap it for another one. Every rule that exists only on the client is a rule the game asks for politely.
The mental test that settles the question case by case is always the same: if the client sent anything at all through this remote, what would happen? If the answer is anything other than "nothing", validation is missing. And "the client would never send that" is not an answer — the client that matters is the one rewritten to send it.
Step by step
Layer 1 — context and permission. Before looking at the data, look at who is asking. The server has to check whether the player has permission to make that request: are they close enough to the shop to buy? Do they have the key that opens the door? Is the character alive?
The documentation lists three situations where this layer is mandatory: a remote that grants a reward or touches progression, a remote that affects other players or shared state, and a remote with a distance, time or permission requirement. If your remote falls into any of those, the context check is not optional.
This is the layer people forget most, because it does not look like security — it looks like a game rule. The client already knows the player is far from the shop, so the button does not even appear. The server knows none of that.
Layer 2 — type and structure. Two different attacks live here.
The first is volume: sending types that are technically valid, but enormous, long or malformed. If the server performs an operation whose cost grows with the length of the string, a huge or malformed string is enough to bog the server down. The type is right; the size is the weapon.
The second is more elegant and more expensive: sending a table in place of an Instance. A complex payload can imitate what would be an ordinary object reference — a table with Name, ClassName, Parent and a nested Price with Value set to zero reads exactly like the real object. If your code only reads fields, it will not notice the difference.
And the official example makes a point of noting the detail that turns theft into loss: a negative value in the price can become a currency payout instead of a charge. It is not just buying for free — it is the game paying the buyer.
The defence is short and always the same: typeof(item) ~= "Instance" knocks out the table, and item:IsDescendantOf(itemsFolder) knocks out the real Instance that came from somewhere else. Both together, never one alone.
Layer 3 — value. Beyond the type, you have to validate the values passing through RemoteEvent and RemoteFunction, making sure they make sense in the context asked for. The documentation is clear about where this is essential: a remote that deals with currency, items, numbers or user content.
The examples are the kind that look obvious once written down: the quantity purchased has to be greater than zero, and the id requested has to correspond to a real item in the game.
Layer 4 — rate. It is no use each call being valid if hundreds fit into a second. This layer does not replace the others; it exists because expensive validation called repeatedly becomes the attack itself.
And it catches a family of problems none of the other three catches. An "open chest" remote can be perfectly validated — player alive, near the chest, chest exists — and still be called sixty times in the same frame, draining a day's reward in a fraction of a second. Each call, alone, is legitimate. The abuse is in the rhythm.
The right interval is not a universal number: it comes out of the game. The question is how long the action takes a human to actually perform, and the limit sits a little below that. Too tight, and a player with an unstable connection is punished for something they did not do. Too loose, and it protects nothing.
The artifact
The module below gathers the four layers in one place. It decides nothing about the game: it returns (ok, reason) and lets the remote handler give up on the first false.
The order inside Validation.number is not cosmetic, and it is the point of the whole article: the finiteness check comes before the range check. The next section explains why.
Common mistakes
The common mistake is not forgetting to validate. It is validating and thinking you are done.
The documentation warns that both inf and NaN are valid numeric types. NaN is dangerous precisely because of its type: to Luau, NaN is a number like any other. And it fails every standard comparison, which lets it walk through checks that look safe.
The official example uses a trading system. The server receives the amount of gold offered and performs two checks any reviewer would approve: it checks the type, it checks the range. The comments in the documentation's own code tell you what happens — at the type check, this passes, because the type of NaN is number; at the range check, this is bypassed, because no comparison with NaN returns true. Not less than zero, not greater than the ceiling. Both come back false, the code carries on, and the server accepts an offer that is not a number.
The damage after that is unbounded: NaN contaminates any arithmetic that touches it, and once written to a DataStore it becomes a permanent balance no comparison can correct — because every comparison with it still comes back false.
The fix fits on one line, and it is the one the documentation recommends: use math.isfinite() to detect and refuse those values. The detail is where the line goes. Placed after the range check, it protects nothing, because the code has already passed. It is the first thing after the type check, always.
Next step
With the four layers in place, the next step is to stop relying on memory: every time validation lives inside the handler, it gets copied, adapted and — by the third remote — forgotten.
Centralising it as the module above does changes the review question. Instead of "does this handler validate?", which requires reading each one's whole body, the question becomes "does this handler call Validation before touching state?", which is answered by looking at the first few lines.
The exercise worth doing today, and one that usually pays more than any refactor: list every OnServerEvent and OnServerInvoke in your game in one place. Next to each one, write which of the four layers it has. The list is almost always shorter than you imagine — and the holes appear on their own, without reading code.
It is worth remembering that ProximityPrompt belongs on that list. It does not look like a remote, but the documentation cites it alongside them precisely because it is an action the client triggers on the server. A prompt with a generous MaxActivationDistance and no distance check on the server is the same hole wearing different clothes.
After that, it is worth walking the other security topics the documentation itself points to in the sidebar — the client-server boundary is the busiest door, but it is not the only one.
Sources
Ready-to-use artifact
-- Validation.lua — one single place to check what arrives from the client.
--
-- The four layers from the documentation, in the order worth running them:
-- 1. context/permission (is this player ALLOWED to ask for this?)
-- 2. type and structure (is it really an Instance? really a short string?)
-- 3. value (is it inside the range? is it finite?)
-- 4. rate (how many times per second?)
--
-- Each function returns (ok, reason). The remote handler never decides
-- on its own: it asks here and gives up on the first "false".
local Validation = {}
local MAX_TEXT_LENGTH = 200
-- ── layer 2: the table dressed up as an Instance ─────────────────────
-- typeof() of a table returns "table", never "Instance". And even a real
-- Instance has to be INSIDE the folder you control.
function Validation.instanceFrom(value, folder)
if typeof(value) ~= "Instance" then
return false, "not an Instance"
end
if not value:IsDescendantOf(folder) then
return false, "Instance outside the allowed folder"
end
return true
end
-- ── layer 2: text ───────────────────────────────────────────────────
function Validation.text(value)
if typeof(value) ~= "string" then
return false, "not a string"
end
if #value == 0 or #value > MAX_TEXT_LENGTH then
return false, "string length outside what is accepted"
end
return true
end
-- ── layer 3: number ─────────────────────────────────────────────────
-- THE ORDER HERE IS NOT COSMETIC. math.isfinite() comes BEFORE the range,
-- because NaN sails straight through any less-than/greater-than comparison.
function Validation.number(value, minimum, maximum)
if typeof(value) ~= "number" then
return false, "not a number"
end
if not math.isfinite(value) then
return false, "non-finite number (NaN or inf)"
end
if value < minimum or value > maximum then
return false, "number outside the range"
end
return true
end
function Validation.positiveInteger(value, maximum)
local ok, reason = Validation.number(value, 1, maximum)
if not ok then
return false, reason
end
if value % 1 ~= 0 then
return false, "expected an integer"
end
return true
end
-- ── layer 1: context ────────────────────────────────────────────────
function Validation.aliveAndNear(player, target, maxDistance)
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
if not humanoid or humanoid.Health <= 0 then
return false, "character dead or missing"
end
local root = character:FindFirstChild("HumanoidRootPart")
if not root or not target then
return false, "no position to compare"
end
if (root.Position - target.Position).Magnitude > maxDistance then
return false, "too far from the target"
end
return true
end
-- ── layer 4: rate ───────────────────────────────────────────────────
local lastUse = {}
function Validation.rate(player, key, interval)
local now = os.clock()
lastUse[player] = lastUse[player] or {}
local previous = lastUse[player][key]
if previous and (now - previous) < interval then
return false, "too fast"
end
lastUse[player][key] = now
return true
end
game:GetService("Players").PlayerRemoving:Connect(function(player)
lastUse[player] = nil
end)
return Validation


