Appearance
Server Authority
Written: 2026-10-14
Learning objectives
- You can name the server the source of truth.
- You can validate context, then type, then value bounds.
- You can reject NaN and infinite numbers.
- You can rate-limit client-triggered logic.
- You can re-validate prompts, receipts, and leaderstats.
Prerequisites
The server decides
The server is the source of truth. Clients only send intents. The server validates each intent, updates its own state, and replicates results. The server authority model and the security tactics guide both state this rule plainly: never trust the client.
A buyer says it wants an apple. The server checks the price, the money, and the distance to the shop. Then it removes money and grants the item. The client never grants anything itself. The client-server boundary guide walks this exact shop flow.
LocalScript in StarterPlayerScripts
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local basket = ReplicatedStorage:WaitForChild("BuyApple")
basket:FireServer("Apple", 1)Send an intent only. Wait for the remote. Pass item and count. Expected behavior: the server receives the request and decides, while the client inventory stays unchanged until the server replies.
Validate in layers
Validate every remote input in three layers. The client-server boundary guide defines all three.
First check context and permission. Is the character alive and present. Does the player own the required item. Is the player close enough for a distance-gated action.
Then check type and structure. Is each argument the expected type. Is the Instance real and in the expected folder, not a spoofed table. Does the table have sane length.
Then check value bounds. Is the count positive and capped. Is the position in range. Is the number finite.
NaN needs special care. typeof(NaN) returns "number", so type checks pass it. Range checks also pass it, because every comparison with NaN is false. The client-server boundary guide shows this bypass and tells you to reject non-finite numbers with math.isfinite.
Script in ServerScriptService
luau
--!strict
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local basket = ReplicatedStorage:WaitForChild("BuyApple")
local lastBuy: { [number]: number } = {}
basket.OnServerEvent:Connect(function(player: Player, item: unknown, count: unknown): ()
local character = player.Character
if not character then
return
end
local humanoid = character:FindFirstChildOfClass("Humanoid")
if not humanoid or humanoid.Health <= 0 then
return
end
if typeof(item) ~= "string" then
return
end
if typeof(count) ~= "number" then
return
end
if not math.isfinite(count) then
return
end
if count < 1 or count > 5 then
return
end
if item ~= "Apple" then
return
end
local now = os.clock()
local ready = lastBuy[player.UserId] or 0
if now - ready < 2 then
return
end
lastBuy[player.UserId] = now
print(player.Name)
end)
Players.PlayerRemoving:Connect(function(player: Player): ()
lastBuy[player.UserId] = nil
end)Create a RemoteEvent named BuyApple in ReplicatedStorage. Checks run in order: life first, then types, then finite bounds and item match, then a two-second rate limit. Expected behavior: valid apple requests print the buyer name, while dead players, bad types, NaN, out-of-range counts, and spam all return silently.
Rate-limit everything clients trigger
Any client-triggered path can be spammed. Remotes, Touched, prompts, and detectors all need server-side limits. Client-side cooldowns help feel, but they never protect you. The client-server boundary guide requires server enforcement.
Use a small timestamp map for simple actions. A token bucket suits bursty actions. Clear entries on PlayerRemoving so the table never leaks. The example above uses the timestamp form with cleanup.
Re-validate prompts and detectors
ProximityPrompts, ClickDetectors, and DragDetectors are not safe by themselves. Exploiters can fire them from any distance, at any rate, even when disabled on the client. The client-server boundary guide documents this spoofing for each object.
Re-check everything on Triggered. Confirm the prompt is enabled on the server, the character is alive, and HumanoidRootPart is near the prompt. Enforce a cooldown.
Script in ServerScriptService
luau
--!strict
local Workspace = game:GetService("Workspace")
local chest = Workspace:WaitForChild("Chest")
local prompt = chest:WaitForChild("GivePrompt") :: ProximityPrompt
local lastUse: { [number]: number } = {}
prompt.Triggered:Connect(function(player: Player): ()
if not prompt.Enabled then
return
end
local character = player.Character
if not character then
return
end
local humanoid = character:FindFirstChildOfClass("Humanoid")
local root = character:FindFirstChild("HumanoidRootPart")
if not humanoid or humanoid.Health <= 0 then
return
end
if not root or not root:IsA("BasePart") then
return
end
local distance = (root.Position - chest:GetPivot().Position).Magnitude
if distance > 12 then
return
end
local now = os.clock()
local ready = lastUse[player.UserId] or 0
if now - ready < 3 then
return
end
lastUse[player.UserId] = now
print(player.Name)
end)Build a part named Chest in Workspace with a ProximityPrompt named GivePrompt inside it. Checks cover enabled state, life, root type, twelve-stud range, and a three-second cooldown. Expected behavior: nearby living players trigger one print per three seconds, while far, dead, or spamming callers get nothing.
Confirm purchases and data on the server
Confirm money purchases with ProcessReceipt on the server. Never trust a client purchase signal. The client-server boundary guide requires ProcessReceipt validation before you grant anything.
Wrap every DataStore call in pcall. Network calls can fail. The data stores guide shows pcall around every read and write. Keep leaderstats as a display copy of server-owned data, never as input.
Script in ServerScriptService
luau
--!strict
local DataStoreService = game:GetService("DataStoreService")
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")
local coins = DataStoreService:GetDataStore("Coins")
local productId = 1234567
local function grant(player: Player, amount: number): ()
local stats = player:FindFirstChild("leaderstats")
if not stats then
return
end
local gems = stats:FindFirstChild("Gems")
if gems and gems:IsA("IntValue") then
gems.Value += amount
end
end
MarketplaceService.ProcessReceipt = function(receipt)
local player = Players:GetPlayerByUserId(receipt.PlayerId)
if not player then
return Enum.ProductPurchaseDecision.NotProcessedYet
end
if receipt.ProductId ~= productId then
return Enum.ProductPurchaseDecision.NotProcessedYet
end
local ok = false
local done, err = pcall(function(): ()
coins:UpdateAsync("Coins" .. tostring(player.UserId), function(current: number?): number
local owned = current or 0
return owned + 50
end)
end)
ok = done
if not ok then
return Enum.ProductPurchaseDecision.NotProcessedYet
end
grant(player, 50)
return Enum.ProductPurchaseDecision.PurchaseGranted
endReplace productId with your own developer product id before you publish. Resolve the buyer, check the product, save with UpdateAsync inside pcall, then update display stats. The receipt parameter takes its type from ProcessReceipt itself, so you leave it unannotated instead of inventing a type. Expected behavior: valid receipts add fifty coins to the store and the display, while failures retry later and unknown products stay unprocessed. ProcessReceipt is the long-standing handler; the reference also documents BindReceiptHandler as the newer alternative for developer-product receipts, and the same rule holds either way: grant only inside the server callback.
Common mistakes
Mistake 1: trusting the client count.
Wrong example below. It grants whatever number arrives.
Script in ServerScriptService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local basket = ReplicatedStorage:WaitForChild("BuyApple")
basket.OnServerEvent:Connect(function(player: Player, count: number): ()
print(player.Name)
print(count)
end)It accepts negative, huge, infinite, and NaN counts. Range and inventory checks silently pass NaN. Fix it with finite checks, bounds, and server-owned prices, as shown above.
Mistake 2: accepting prompt fires without distance checks.
Wrong example below. It grants on any fire.
Script in ServerScriptService
luau
--!strict
local Workspace = game:GetService("Workspace")
local chest = Workspace:WaitForChild("Chest")
local prompt = chest:WaitForChild("GivePrompt") :: ProximityPrompt
prompt.Triggered:Connect(function(player: Player): ()
print(player.Name)
end)It fails because prompt events can be spoofed from anywhere. The client-server boundary guide warns that only Triggered has a server distance check, and even that needs your own game logic. Fix it with enabled, alive, distance, and rate checks.
Mistake 3: calling DataStore without pcall.
Wrong example below. It lets a network failure throw.
Script in ServerScriptService
luau
--!strict
local DataStoreService = game:GetService("DataStoreService")
local coins = DataStoreService:GetDataStore("Coins")
local saved = coins:GetAsync("Coins100")
print(saved)It throws when the backend hiccups. The data stores guide requires pcall around these calls. Fix it by wrapping the call and branching on success.
Script in ServerScriptService
luau
--!strict
local DataStoreService = game:GetService("DataStoreService")
local coins = DataStoreService:GetDataStore("Coins")
local done, saved = pcall(function()
return coins:GetAsync("Coins100")
end)
if done then
print(saved)
endExercises
- Reject a NaN count with a finite check.
- Add a two-second per-player cooldown to a remote.
- Re-validate a prompt with alive and distance checks.
Solution 1
Script in ServerScriptService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local basket = ReplicatedStorage:WaitForChild("BuyApple")
basket.OnServerEvent:Connect(function(player: Player, count: unknown): ()
if typeof(count) ~= "number" then
return
end
if not math.isfinite(count) then
return
end
if count < 1 or count > 5 then
return
end
print(player.Name)
end)Expected behavior: NaN, infinite, and out-of-range counts all return silently.
Solution 2
Script in ServerScriptService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local basket = ReplicatedStorage:WaitForChild("BuyApple")
local last: { [number]: number } = {}
basket.OnServerEvent:Connect(function(player: Player): ()
local now = os.clock()
local ready = last[player.UserId] or 0
if now - ready < 2 then
return
end
last[player.UserId] = now
print(player.Name)
end)Expected behavior: each player prints at most once per two seconds.
Solution 3
Script in ServerScriptService
luau
--!strict
local Workspace = game:GetService("Workspace")
local chest = Workspace:WaitForChild("Chest")
local prompt = chest:WaitForChild("GivePrompt") :: ProximityPrompt
prompt.Triggered:Connect(function(player: Player): ()
local character = player.Character
if not character then
return
end
local humanoid = character:FindFirstChildOfClass("Humanoid")
local root = character:FindFirstChild("HumanoidRootPart")
if not humanoid or humanoid.Health <= 0 then
return
end
if not root or not root:IsA("BasePart") then
return
end
local distance = (root.Position - chest:GetPivot().Position).Magnitude
if distance > 12 then
return
end
print(player.Name)
end)Expected behavior: only nearby living players print.
Summary
Decide on the server. Check permission, then shape, then bounds. Reject non-finite numbers. Limit rates. Re-check prompts and receipts. Display leaderstats without trusting them. Next, present it to players in UI and Input.