---
url: /roblox-fundamentals/server-authority.md
description: Trust nothing from clients. Validate every input in layers, server-side.
---

# Server Authority

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

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

* [Remotes and Networking](./remotes-and-networking.md)
* [Players and Characters](./players-and-characters.md)
* [Task and Async](./task-and-async.md)

## 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](https://create.roblox.com/docs/en-us/projects/server-authority) model and the [security tactics](https://create.roblox.com/docs/en-us/scripting/security/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](https://create.roblox.com/docs/en-us/scripting/security/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](https://create.roblox.com/docs/en-us/scripting/security/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](https://create.roblox.com/docs/en-us/scripting/security/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](https://create.roblox.com/docs/en-us/scripting/security/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](https://create.roblox.com/docs/en-us/reference/engine/classes/ProximityPrompt), 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](https://create.roblox.com/docs/en-us/scripting/security/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](https://create.roblox.com/docs/en-us/scripting/security/client-server-boundary) guide requires ProcessReceipt validation before you grant anything.

Wrap every [DataStore](https://create.roblox.com/docs/en-us/cloud-services/data-stores) call in `pcall`. Network calls can fail. The [data stores](https://create.roblox.com/docs/en-us/cloud-services/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
end
```

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

## Exercises

1. Reject a NaN count with a finite check.
2. Add a two-second per-player cooldown to a remote.
3. Re-validate a prompt with alive and distance checks.

::: details 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.
:::

::: details 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.
:::

::: details 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](./ui-and-input.md).
