---
url: /advanced/security-and-production.md
description: >-
  Layered validation, exploit case studies with fixes, failure handling, and
  debugging live games.
---

# Security and Production

*Written: {{ $frontmatter.date }}*

## 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

* [Server Authority](../roblox-fundamentals/server-authority.md)
* [Remotes and Networking](../roblox-fundamentals/remotes-and-networking.md)
* [Architecture](./architecture.md)

## 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](https://create.roblox.com/docs/en-us/scripting/security/security-tactics) guide states the core rule. Never trust the client. The [server authority](https://create.roblox.com/docs/en-us/projects/server-authority) model makes the server the source of truth. The [client-server boundary](https://create.roblox.com/docs/en-us/scripting/security/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](https://create.roblox.com/docs/en-us/scripting/security/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](https://sleitnick.github.io/RbxUtil/api/ServerComm) and [RemoteSignal](https://sleitnick.github.io/RbxUtil/api/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 HarvestService
```

The 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](https://create.roblox.com/docs/en-us/scripting/security/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 SprintService
```

Comm calls `CreateSignal`, `Connect`, and `Fire` per the [ServerComm](https://sleitnick.github.io/RbxUtil/api/ServerComm) and [RemoteSignal](https://sleitnick.github.io/RbxUtil/api/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](https://create.roblox.com/docs/en-us/scripting/security/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](https://create.roblox.com/docs/en-us/scripting/events/remote) 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 ShopCatalog
```

**ModuleScript 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 ShopService
```

DataService 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](https://create.roblox.com/docs/en-us/scripting/security/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 CombatService
```

Comm calls follow the [ServerComm](https://sleitnick.github.io/RbxUtil/api/ServerComm) and [RemoteSignal](https://sleitnick.github.io/RbxUtil/api/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](https://create.roblox.com/docs/en-us/cloud-services/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 SaveHelper
```

The 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](https://create.roblox.com/docs/en-us/projects/server-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](https://sleitnick.github.io/RbxUtil/api/ServerComm). 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 AuditLog
```

Expected 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](https://create.roblox.com/docs/en-us/scripting/security/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](https://create.roblox.com/docs/en-us/cloud-services/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)
end
```

Expected behavior: outages print nothing instead of killing the thread.

## Exercises

1. Add permission checks to a bare remote.
2. Fix a shop handler that trusts client price.
3. Add transient retry to a save call.

::: details 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 GateCheck
```

Expected behavior: only nearby living players pass, while dead, missing, and far callers fail.
:::

::: details 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 FixedShop
```

Expected behavior: buys use frozen server prices and server balances, while unknown items and non-finite prices fail.
:::

::: details 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 RetrySave
```

Expected 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](https://create.roblox.com/docs/reference/engine/classes/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](https://create.roblox.com/docs/reference/engine/classes/TextService). 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
end
```

## Summary

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](./performance.md).
