Appearance
Security and Production
Written: 2026-10-14
Learning objectives
- You can name trust boundaries and keep secrets server-side.
- You can validate permission, then type, then value, then rate in one handler.
- You can fix speed, shop, and damage exploits with server checks.
- You can separate transient failures from permanent ones with pcall.
- You can start systems in one order and debug live games with logs.
Prerequisites
Trust boundaries recap
The server is trusted. Studio-built map parts you anchored and Shared config you froze are trusted. Everything else from the client is distrusted. Position, speed, price, damage, inventory, and cooldowns from the client are treated as claims to verify.
The security tactics guide states the core rule. Never trust the client. The server authority model makes the server the source of truth. The client-server boundary guide shows how that rule applies to remotes, prompts, and detectors.
You keep secrets in ServerScriptService and ServerStorage. You never place formulas, drop rates, or admin keys in ReplicatedStorage. Exploiters decompile every replicated ModuleScript. You treat leaderstats and attributes as paint. You read them for display. You never decide with them.
Server checks add work. You recheck distance the client already checked. You reload prices the client already shows. Apply the strictest checks where a value affects money, items, or other players.
Validate in four layers
Check every client-triggered path in the same order: permission and context, then type and structure, then value and bounds, then rate. Return silently on the first failure. Log a short line for yourself without leaking logic to the caller.
The client-server boundary guide defines these layers. Context covers whether the actor may act, type whether the shape is valid, value whether the number is in range, and rate whether the caller sends too often.
This handler grants a harvest pickup. It checks life, distance, tool ownership, types, finite bounds, and a per-player cooldown, using Comm binding calls CreateSignal and Connect per the ServerComm and RemoteSignal pages on the server.
ModuleScript in ServerScriptService named HarvestService
luau
--!strict
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Janitor = require(Packages:WaitForChild("Janitor")) :: any
local HarvestService = {}
local lastHarvest: { [number]: number } = {}
local grove = Workspace:WaitForChild("BerryGrove") :: BasePart
local pickupRange = 12
local cooldownSeconds = 2
function HarvestService.start(janitor: any, serverComm: any): ()
local signal = serverComm:CreateSignal("Harvest") :: any
if signal == nil then
return
end
local connection = signal:Connect(function(player: Player, bushId: unknown): ()
local character = player.Character
if character == nil then
return
end
local humanoid = character:FindFirstChildOfClass("Humanoid")
if humanoid == nil then
return
end
if humanoid.Health <= 0 then
return
end
local root = character:FindFirstChild("HumanoidRootPart")
if root == nil then
return
end
if not root:IsA("BasePart") then
return
end
if typeof(bushId) ~= "string" then
return
end
if #bushId < 3 or #bushId > 24 then
return
end
local bush = grove:FindFirstChild(bushId)
if bush == nil then
return
end
if not bush:IsA("BasePart") then
return
end
local distance = (root.Position - bush.Position).Magnitude
if not math.isfinite(distance) then
return
end
if distance > pickupRange then
return
end
local now = os.clock()
local ready = lastHarvest[player.UserId] or 0
if now - ready < cooldownSeconds then
return
end
lastHarvest[player.UserId] = now
signal:Fire(player, bushId)
end)
janitor:Add(connection)
Players.PlayerRemoving:Connect(function(leaving: Player): ()
lastHarvest[leaving.UserId] = nil
end)
end
return HarvestServiceThe handler checks alive state and root type, then string type and length. It resolves the bush inside the grove and enforces a twelve-stud range and a two-second cooldown. Expected behavior: nearby living players harvest one bush per two seconds and get one echo, while dead, far, malformed, NaN, and spamming callers get nothing.
Speed pickup: wrong then fixed
Wrong version trusts client speed. It grants whenever the client says it arrived fast. Exploiters teleport and claim the bonus.
Script in ServerScriptService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local event = ReplicatedStorage:WaitForChild("SpeedPickup") :: RemoteEvent
event.OnServerEvent:Connect(function(player: Player, seconds: number): ()
print(player.Name)
print(seconds)
end)It fails because seconds comes from the attacker. They send 0.01 and win every race. The client-server boundary guide tells you to measure on the server instead.
The fixed version measures server time. It starts the clock when the round starts and checks the claimant against that clock.
ModuleScript in ServerScriptService named SprintService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Janitor = require(Packages:WaitForChild("Janitor")) :: any
local SprintService = {}
local roundStart = 0
local bestSeconds = 30
function SprintService.begin(): ()
roundStart = os.clock()
end
function SprintService.start(janitor: any, serverComm: any): ()
local signal = serverComm:CreateSignal("ClaimSprint") :: any
if signal == nil then
return
end
janitor:Add(signal:Connect(function(player: Player): ()
local character = player.Character
if character == nil then
return
end
local humanoid = character:FindFirstChildOfClass("Humanoid")
if humanoid == nil then
return
end
if humanoid.Health <= 0 then
return
end
if roundStart <= 0 then
return
end
local elapsed = os.clock() - roundStart
if not math.isfinite(elapsed) then
return
end
if elapsed <= 0 or elapsed > 120 then
return
end
if elapsed < bestSeconds then
bestSeconds = elapsed
end
signal:Fire(player, elapsed)
end))
end
return SprintServiceComm calls CreateSignal, Connect, and Fire per the ServerComm and RemoteSignal pages. The handler ignores client time entirely and measures elapsed on the server, bounded between zero and one hundred twenty. Expected behavior: only living players in a started round set best times, and spoofed times never reach the board.
Shop price: wrong then fixed
Wrong version trusts client price. It subtracts whatever the client sends. Exploiters send zero or negative and gain money.
Script in ServerScriptService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local event = ReplicatedStorage:WaitForChild("BuyItem") :: RemoteEvent
event.OnServerEvent:Connect(function(player: Player, item: string, price: number): ()
local stats = player:FindFirstChild("leaderstats")
if stats == nil then
return
end
local coins = stats:FindFirstChild("Coins")
if coins ~= nil and coins:IsA("IntValue") then
coins.Value -= math.floor(price)
end
end)It fails on three counts. Price comes from the attacker. Balance lives in display stats. NaN passes every compare. The client-server boundary guide shows this spoof with a fake item table and tells you to check IsDescendantOf plus server prices plus math.isfinite.
The fixed version reads prices from frozen Shared and balances from DataService. It rejects NaN and infinite with math.isfinite. The remote events guide defines the player-first ordering.
ModuleScript in ReplicatedStorage named ShopCatalog
luau
--!strict
local ShopCatalog = {
Apple = { price = 5, stock = 99 },
Bread = { price = 3, stock = 99 },
}
table.freeze(ShopCatalog)
return ShopCatalogModuleScript in ServerScriptService named ShopService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Janitor = require(Packages:WaitForChild("Janitor")) :: any
local Catalog = require(ReplicatedStorage:WaitForChild("ShopCatalog"))
local DataService = require(ServerScriptService:WaitForChild("DataService"))
local ShopService = {}
local lastBuy: { [number]: number } = {}
function ShopService.start(janitor: any, serverComm: any): ()
local signal = serverComm:CreateSignal("BuyItem") :: any
if signal == nil then
return
end
janitor:Add(signal:Connect(function(player: Player, item: unknown, count: unknown): ()
local character = player.Character
if character == nil 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
local entry = Catalog[item]
if entry == nil then
return
end
local price = (entry :: any).price
if typeof(price) ~= "number" then
return
end
if not math.isfinite(price) then
return
end
local total = price * math.floor(count)
if total <= 0 or total > 500 then
return
end
local now = os.clock()
local ready = lastBuy[player.UserId] or 0
if now - ready < 1 then
return
end
lastBuy[player.UserId] = now
local ok = DataService.spendCoins(player, total)
if not ok then
return
end
DataService.grantItem(player, item, math.floor(count))
signal:Fire(player, DataService.getCoins(player))
end))
end
return ShopServiceDataService calls spendCoins, grantItem, and getCoins are server-owned helpers you implement behind one session. The handler checks life, then string and finite count, then catalog price, then a one-second rate limit. Expected behavior: valid buys spend server balances and grant items, while zero prices, NaN counts, unknown items, and spam return silently.
Damage event: wrong then fixed
Wrong version trusts client damage. It applies any number to any target. Exploiters one-shot the map.
Script in ServerScriptService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local event = ReplicatedStorage:WaitForChild("HitEnemy") :: RemoteEvent
event.OnServerEvent:Connect(function(player: Player, target: Instance, damage: number): ()
local humanoid = target:FindFirstChildOfClass("Humanoid")
if humanoid ~= nil then
humanoid.Health -= damage
end
end)It fails because target and damage both come from the attacker. Attackers pass another player, huge damage, or NaN which poisons health math. The security tactics guide tells you to own weapon state on the server.
Fixed version owns range, weapon, line of sight, ammo, and cooldown on the server. It treats target as a hint and re-derives damage from server tuning.
ModuleScript in ServerScriptService named CombatService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Janitor = require(Packages:WaitForChild("Janitor")) :: any
local CombatService = {}
local lastShot: { [number]: number } = {}
local ammo: { [number]: number } = {}
local baseDamage = 10
local maxRange = 60
local fireGap = 0.4
function CombatService.start(janitor: any, serverComm: any): ()
local signal = serverComm:CreateSignal("SwingSword") :: any
if signal == nil then
return
end
janitor:Add(signal:Connect(function(player: Player, targetPos: unknown): ()
if typeof(targetPos) ~= "Vector3" then
return
end
if not math.isfinite(targetPos.X) then
return
end
if not math.isfinite(targetPos.Y) then
return
end
if not math.isfinite(targetPos.Z) then
return
end
local character = player.Character
if character == nil then
return
end
local root = character:FindFirstChild("HumanoidRootPart")
if root == nil then
return
end
if not root:IsA("BasePart") then
return
end
local humanoid = character:FindFirstChildOfClass("Humanoid")
if humanoid == nil then
return
end
if humanoid.Health <= 0 then
return
end
local distance = (root.Position - targetPos).Magnitude
if not math.isfinite(distance) then
return
end
if distance > maxRange then
return
end
local now = os.clock()
local ready = lastShot[player.UserId] or 0
if now - ready < fireGap then
return
end
local left = ammo[player.UserId] or 10
if left <= 0 then
return
end
lastShot[player.UserId] = now
ammo[player.UserId] = left - 1
local params = RaycastParams.new()
params.ExcludeInstances = { character }
local result = Workspace:Raycast(root.Position, (targetPos - root.Position).Unit * distance, params)
local hitModel = Workspace:FindFirstChild("TrainingDummy")
if hitModel == nil then
return
end
if result == nil then
return
end
if not result.Instance:IsDescendantOf(hitModel) then
return
end
local dummyHumanoid = hitModel:FindFirstChildOfClass("Humanoid")
if dummyHumanoid == nil then
return
end
dummyHumanoid.Health -= baseDamage
end))
end
return CombatServiceComm calls follow the ServerComm and RemoteSignal pages. The handler checks Vector3 finiteness per axis, life, and sixty-stud range. It enforces ammo and a 0.4 second gap. It raycasts with the shooter's character excluded, then confirms the hit belongs to the dummy before dealing damage. Expected behavior: clean swings within range and line of sight deal ten damage to the dummy, while far, NaN, spammed, empty, and walled shots do nothing.
Latency shifts client and server positions. Tight ranges reject fair hits. Loose ranges admit teleport hits. Start at weapon range plus four studs. Tighten when exploit reports drop. Widen when fair players complain.
Fail safely with pcall
Cloud calls fail. DataStores throttle. HTTP times out. Marketplace hiccups. You wrap every one in pcall. The data stores guide wraps GetAsync, SetAsync, UpdateAsync, and IncrementAsync the same way.
You split failures in two. Transient means retry. Permanent means stop and tell the player. Throttle, timeout, and empty session are transient. Bad key, bad shape, and auth errors are permanent. You retry transient with backoff. You never retry permanent in a loop.
ModuleScript in ServerScriptService named SaveHelper
luau
--!strict
local DataStoreService = game:GetService("DataStoreService")
local SaveHelper = {}
local store = DataStoreService:GetDataStore("Wallet_1")
function SaveHelper.loadCoins(userId: number): number
local tries = 0
while tries < 3 do
tries += 1
local done, saved = pcall(function()
return store:GetAsync("Player_" .. tostring(userId))
end)
if done then
if typeof(saved) ~= "number" then
return 0
end
if not math.isfinite(saved) then
return 0
end
return math.floor(saved)
end
task.wait(tries)
end
return 0
end
function SaveHelper.saveCoins(userId: number, total: number): boolean
if not math.isfinite(total) then
return false
end
if total < 0 or total > 1_000_000 then
return false
end
local done, _ = pcall(function(): ()
store:SetAsync("Player_" .. tostring(userId), math.floor(total))
end)
if done then
return true
end
return false
end
return SaveHelperThe handler retries loads three times with growing waits and validates shape after each success. It bounds-checks saves before the network call. Expected behavior: short outages load on retry, corrupt values read as zero, and out-of-range saves fail fast without a call.
Change retry behavior when failures cluster. Three quick retries help one hiccup but hammer a real outage. Add jitter and a circuit breaker when error rates pass one percent, then stop retries and serve cached reads instead.
Start in one order
Start deterministically. Start DataService first, then gameplay Services in dependency order, then Comm bindings, then readiness flags. Controllers start without yielding forever and render when echoes arrive.
Never consume a sibling inside start setup. Reference during wiring and consume after every module is wired. The authority techniques guide applies the same wire-then-consume rule to simulation binds.
Script in ServerScriptService named BootServer
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Packages = ReplicatedStorage:WaitForChild("Packages")
local Comm = require(Packages:WaitForChild("Comm")) :: any
local Janitor = require(Packages:WaitForChild("Janitor")) :: any
local DataService = require(ServerScriptService:WaitForChild("DataService"))
local SprintService = require(ServerScriptService:WaitForChild("SprintService"))
local ShopService = require(ServerScriptService:WaitForChild("ShopService"))
DataService.start()
SprintService.begin()
local janitor = Janitor.new() :: any
if janitor == nil then
return
end
local folder = Instance.new("Folder")
folder.Name = "GameComm"
folder.Parent = ReplicatedStorage
local ServerComm = Comm.ServerComm :: any
local serverComm = ServerComm.new(folder, "GameComm") :: any
if serverComm == nil then
return
end
SprintService.start(janitor, serverComm)
ShopService.start(janitor, serverComm)Comm construction ServerComm.new follows the ServerComm page. Startup starts data, then round clock, then Services. Create the Comm folder with Parent last. Expected behavior: joins load saves before any buy or sprint claim can run.
Debug production like a detective
Log small lines with context: player id, action, and decision. Never log secrets or full payloads. Keep logs greppable. One line per rejected remote with a reason code helps more than a stack of prints.
ModuleScript in ServerScriptService named AuditLog
luau
--!strict
local AuditLog = {}
function AuditLog.reject(userId: number, action: string, reason: string): ()
if userId <= 0 then
return
end
if #action < 2 or #reason < 2 then
return
end
print(userId)
print(action)
print(reason)
end
return AuditLogExpected behavior: each rejection prints three short lines you can filter in Output and live logs.
Reproduce with two clients in Studio. Start a server plus two players. Play fair on one and spam and teleport on the other. Watch server Output, not client Output. Server Output shows truth and client Output shows paint. Test prompts, remotes, and purchase flows from both windows before you publish.
Keep a short checklist. Two clients join clean. Spammer gets throttled. Teleporter gets distance-rejected. Buyer with no money gets denied. Killer with bad damage gets ignored. Save outage retries then serves cache. Ship when all five pass.
Common mistakes
Mistake 1: checking value before permission.
This wrong example checks count before life and distance.
Script in ServerScriptService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local event = ReplicatedStorage:WaitForChild("PickBerry") :: RemoteEvent
event.OnServerEvent:Connect(function(player: Player, count: unknown): ()
if typeof(count) ~= "number" then
return
end
if count < 1 or count > 5 then
return
end
print(player.Name)
end)It fails because dead and far players reach the value check. That wastes work and misses context exploits. Fix it with permission first, as in HarvestService. Check character, life, and distance before types and bounds.
Mistake 2: trusting leaderstats for money.
This wrong example reads display coins for a purchase.
Script in ServerScriptService
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local event = ReplicatedStorage:WaitForChild("BuySword") :: RemoteEvent
event.OnServerEvent:Connect(function(player: Player): ()
local stats = player:FindFirstChild("leaderstats")
if stats == nil then
return
end
local coins = stats:FindFirstChild("Coins")
if coins ~= nil and coins:IsA("IntValue") and coins.Value >= 50 then
coins.Value -= 50
print(player.Name)
end
end)It fails because the client can edit its own display tree. The client-server boundary guide tells you to keep authoritative balances server-side. Fix it with DataService spendCoins plus frozen catalog prices, as in ShopService.
Mistake 3: calling cloud without pcall.
This wrong example lets a hiccup throw.
Script in ServerScriptService
luau
--!strict
local DataStoreService = game:GetService("DataStoreService")
local store = DataStoreService:GetDataStore("Wallet_1")
local saved = store:GetAsync("Player_7")
print(saved)It throws when the backend throttles. The data stores guide requires pcall around these calls. Fix it with the retry loop in SaveHelper. Branch on success before you use the value.
Script in ServerScriptService
luau
--!strict
local DataStoreService = game:GetService("DataStoreService")
local store = DataStoreService:GetDataStore("Wallet_1")
local done, saved = pcall(function()
return store:GetAsync("Player_7")
end)
if done then
print(saved)
endExpected behavior: outages print nothing instead of killing the thread.
Exercises
- Add permission checks to a bare remote.
- Fix a shop handler that trusts client price.
- Add transient retry to a save call.
Solution 1
ModuleScript in ServerScriptService named GateCheck
luau
--!strict
local Workspace = game:GetService("Workspace")
local GateCheck = {}
local altar = Workspace:WaitForChild("Altar") :: BasePart
function GateCheck.allowed(player: Player): boolean
local character = player.Character
if character == nil then
return false
end
local humanoid = character:FindFirstChildOfClass("Humanoid")
if humanoid == nil then
return false
end
if humanoid.Health <= 0 then
return false
end
local root = character:FindFirstChild("HumanoidRootPart")
if root == nil then
return false
end
if not root:IsA("BasePart") then
return false
end
local distance = (root.Position - altar.Position).Magnitude
if not math.isfinite(distance) then
return false
end
if distance > 10 then
return false
end
return true
end
return GateCheckExpected behavior: only nearby living players pass, while dead, missing, and far callers fail.
Solution 2
ModuleScript in ServerScriptService named FixedShop
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Catalog = require(ReplicatedStorage:WaitForChild("ShopCatalog"))
local DataService = require(ServerScriptService:WaitForChild("DataService"))
local FixedShop = {}
function FixedShop.tryBuy(player: Player, item: unknown): boolean
if typeof(item) ~= "string" then
return false
end
local entry = Catalog[item]
if entry == nil then
return false
end
local price = (entry :: any).price
if typeof(price) ~= "number" then
return false
end
if not math.isfinite(price) then
return false
end
if price <= 0 then
return false
end
return DataService.spendCoins(player, price)
end
return FixedShopExpected behavior: buys use frozen server prices and server balances, while unknown items and non-finite prices fail.
Solution 3
ModuleScript in ServerScriptService named RetrySave
luau
--!strict
local DataStoreService = game:GetService("DataStoreService")
local RetrySave = {}
local store = DataStoreService:GetDataStore("Wallet_1")
function RetrySave.write(userId: number, total: number): boolean
if not math.isfinite(total) then
return false
end
local tries = 0
while tries < 3 do
tries += 1
local done, _ = pcall(function(): ()
store:SetAsync("Player_" .. tostring(userId), math.floor(total))
end)
if done then
return true
end
task.wait(tries)
end
return false
end
return RetrySaveExpected behavior: short outages succeed on retry, while persistent failures return false after three tries.
Rate limits and text filtering
Validation answers "is this request legal"; rate limiting answers "is this sender behaving". Client-to-server remotes throttle at roughly 500 requests per second per client, shared across remotes of the same type (see UnreliableRemoteEvent), so an exploiter spamming FireServer can degrade the whole channel. Keep a per-player token bucket on the server: each request spends a token, tokens refill over time, and empty buckets get silent return plus a logged strike. Sanity-check physics too, reject teleports beyond human speed and purchases beyond wallet contents.
luau
--!strict
local buckets: { [Player]: number } = {}
local MAX_TOKENS = 10
local function allowRequest(player: Player): boolean
local tokens = buckets[player]
if tokens == nil then
tokens = MAX_TOKENS
end
if tokens <= 0 then
return false
end
buckets[player] = tokens - 1
return true
end
task.spawn(function(): ()
while true do
task.wait(1)
table.clear(buckets)
end
end)Every player-visible string needs server-side filtering with TextService:FilterStringAsync. Filter once per submission inside pcall; on failure, display nothing. Then distribute per-audience results with GetNonChatStringForUserAsync, never the raw input. The method yields, may throw service errors (do not hand-retry, it retries internally), and requires the author to be online on the current server.
luau
--!strict
local TextService = game:GetService("TextService")
local function filterPetName(petName: string, fromUserId: number): string?
local done, result = pcall(function()
return TextService:FilterStringAsync(petName, fromUserId)
end)
if not done or result == nil then
return nil
end
return result
endSummary
Distrust the client and decide on the server. Check permission, then shape, then bounds, then rate with per-player token buckets. Filter every visible string with TextService and display nothing on failure. Own speed clocks, prices, and damage on the server. Retry transient cloud faults and fail fast on permanent ones. Start data first and debug with two clients. Next, spend budget wisely in Performance.