How to Secure Roblox RemoteEvents with Validation, Rate Limits, and Server Authority
Posted: Sat Jul 18, 2026 12:07 am
How to Secure Roblox RemoteEvents
Treat a client-to-server RemoteEvent as a request, not proof that an action is valid. Roblox documents RemoteEvents as asynchronous, one-way communication. When a client fires one, OnServerEvent receives the sending Player as its first parameter.
Exploiters can manipulate locally running code and repeatedly submit remote calls with client-controlled arguments. Client-side checks can improve the ordinary interface, but critical enforcement must remain on the server.
Validate every request on the server
Before an action changes gameplay state, validate:
Recommended layout
In this design, the client submits only a stable item identifier. The catalog and mutable purchase state remain in server-side code.
Token-bucket rate limiter
Roblox recommends server-side rate limiting for client-triggered logic and presents token buckets as a way to allow short bursts while limiting sustained traffic. Place this ModuleScript at ServerScriptService/RateLimiter:
Capacity and refill values depend on the action and expected legitimate behavior. Use separate action names so unrelated requests do not share a bucket unintentionally.
Server-authoritative shop handler
Place this Script at ServerScriptService/SecureShop:
The handler obtains the price, balance, ownership state, shop position, and Tool template from server-controlled state. The in-memory balance is demonstration data; persistent transaction flows require concurrency-aware server-side design to avoid corrupted state or duplicated items.
Client request
The LocalScript sends only the item identifier:
The button state is an interface behavior, not a security control. The server still validates and rate-limits every request.
Reject non-finite numbers
Roblox warns that NaN and infinity have the number type and can undermine ordinary comparisons or calculations. Reject them explicitly before applying range or gameplay checks:
A structurally valid number must still be checked against server-owned rules such as balance, inventory capacity, and permissions.
Combat requests
Do not accept client-decided damage. For weapon requests, Roblox recommends server checks for plausible shot positions, obstacles, firing cadence, server-tracked ammunition, team rules, target life state, and weapon or player state. Damage and hit approval should come from server-owned definitions and server validation rather than a number supplied by the client.
Rejections and enforcement
Reject invalid requests before they change gameplay state. Roblox recommends preventing harm first, treating individual heuristics as signals rather than definitive proof, and using proportional responses that account for possible false positives.
Checklist
Before shipping a client-to-server remote, confirm that its handler:
Treat a client-to-server RemoteEvent as a request, not proof that an action is valid. Roblox documents RemoteEvents as asynchronous, one-way communication. When a client fires one, OnServerEvent receives the sending Player as its first parameter.
Exploiters can manipulate locally running code and repeatedly submit remote calls with client-controlled arguments. Client-side checks can improve the ordinary interface, but critical enforcement must remain on the server.
Validate every request on the server
Before an action changes gameplay state, validate:
- Argument types, sizes, structures, and permitted values.
- Submitted identifiers against server-owned data.
- Character state, distance, timing, and permissions.
- Server-owned facts such as prices, balances, ownership, ammunition, damage, and cooldowns.
- Request frequency with an action-appropriate, per-player rate limit.
Recommended layout
Code: Select all
ReplicatedStorage
└── Remotes
├── BuyItem (RemoteEvent)
└── PurchaseResult (RemoteEvent)
ServerScriptService
├── RateLimiter (ModuleScript)
└── SecureShop (Script)
ServerStorage
└── Items
└── BronzeSword (Tool)
Workspace
└── ShopCounter (Part)Token-bucket rate limiter
Roblox recommends server-side rate limiting for client-triggered logic and presents token buckets as a way to allow short bursts while limiting sustained traffic. Place this ModuleScript at ServerScriptService/RateLimiter:
Code: Select all
local Players = game:GetService("Players")
local RateLimiter = {}
type Bucket = {
tokens: number,
updatedAt: number,
}
local buckets: {[Player]: {[string]: Bucket}} = {}
function RateLimiter.consume(
player: Player,
action: string,
capacity: number,
refillPerSecond: number
): boolean
local now = os.clock()
local playerBuckets = buckets[player]
if not playerBuckets then
playerBuckets = {}
buckets[player] = playerBuckets
end
local bucket = playerBuckets[action]
if not bucket then
bucket = {
tokens = capacity,
updatedAt = now,
}
playerBuckets[action] = bucket
end
local elapsed = now - bucket.updatedAt
bucket.updatedAt = now
bucket.tokens = math.min(
capacity,
bucket.tokens + elapsed * refillPerSecond
)
if bucket.tokens < 1 then
return false
end
bucket.tokens -= 1
return true
end
Players.PlayerRemoving:Connect(function(player)
buckets[player] = nil
end)
return RateLimiterServer-authoritative shop handler
Place this Script at ServerScriptService/SecureShop:
Code: Select all
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerStorage = game:GetService("ServerStorage")
local RateLimiter = require(script.Parent.RateLimiter)
local remotes = ReplicatedStorage:WaitForChild("Remotes")
local buyItem = remotes:WaitForChild("BuyItem") :: RemoteEvent
local purchaseResult = remotes:WaitForChild("PurchaseResult") :: RemoteEvent
local itemFolder = ServerStorage:WaitForChild("Items")
local shopCounter = workspace:WaitForChild("ShopCounter") :: BasePart
local MAX_SHOP_DISTANCE = 16
local MAX_ITEM_ID_LENGTH = 40
type CatalogItem = {
price: number,
templateName: string,
}
local catalog: {[string]: CatalogItem} = {
BronzeSword = {
price = 100,
templateName = "BronzeSword",
},
}
-- Demonstration state only; replace with server-side profile data.
local balances: {[Player]: number} = {}
local ownedItems: {[Player]: {[string]: boolean}} = {}
local function initializePlayer(player: Player)
balances[player] = 500
ownedItems[player] = {}
end
Players.PlayerAdded:Connect(initializePlayer)
for _, player in Players:GetPlayers() do
initializePlayer(player)
end
Players.PlayerRemoving:Connect(function(player)
balances[player] = nil
ownedItems[player] = nil
end)
local function sendResult(player: Player, ok: boolean, code: string)
purchaseResult:FireClient(player, ok, code)
end
local function reject(player: Player, code: string, suspicious: boolean)
if suspicious then
warn(string.format(
"[RemoteReject] remote=BuyItem userId=%d reason=%s",
player.UserId,
code
))
end
sendResult(player, false, code)
end
buyItem.OnServerEvent:Connect(function(player: Player, itemId: unknown)
if not RateLimiter.consume(player, "BuyItem", 4, 1) then
reject(player, "RATE_LIMITED", false)
return
end
if typeof(itemId) ~= "string"
or #itemId == 0
or #itemId > MAX_ITEM_ID_LENGTH then
reject(player, "BAD_REQUEST", true)
return
end
local item = catalog[itemId]
if not item then
reject(player, "UNKNOWN_ITEM", true)
return
end
local character = player.Character
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
local root = character and character:FindFirstChild("HumanoidRootPart")
if not humanoid
or humanoid.Health <= 0
or not root
or not root:IsA("BasePart") then
reject(player, "INVALID_STATE", false)
return
end
if (root.Position - shopCounter.Position).Magnitude > MAX_SHOP_DISTANCE then
reject(player, "TOO_FAR_AWAY", true)
return
end
local balance = balances[player]
local owned = ownedItems[player]
local backpack = player:FindFirstChildOfClass("Backpack")
if balance == nil or not owned or not backpack then
reject(player, "TEMPORARILY_UNAVAILABLE", false)
return
end
if owned[itemId] then
reject(player, "ALREADY_OWNED", false)
return
end
if balance < item.price then
reject(player, "NOT_ENOUGH_COINS", false)
return
end
local template = itemFolder:FindFirstChild(item.templateName)
if not template or not template:IsA("Tool") then
warn(string.format("[ShopConfig] Missing Tool template for %s", itemId))
reject(player, "TEMPORARILY_UNAVAILABLE", false)
return
end
local grantedTool = template:Clone()
balances[player] = balance - item.price
owned[itemId] = true
grantedTool.Parent = backpack
sendResult(player, true, "PURCHASED")
end)Client request
The LocalScript sends only the item identifier:
Code: Select all
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local remotes = ReplicatedStorage:WaitForChild("Remotes")
local buyItem = remotes:WaitForChild("BuyItem")
local purchaseResult = remotes:WaitForChild("PurchaseResult")
local button = script.Parent
local statusLabel = button.Parent:WaitForChild("StatusLabel")
button.Activated:Connect(function()
button.Active = false
buyItem:FireServer("BronzeSword")
end)
purchaseResult.OnClientEvent:Connect(function(ok, code)
button.Active = true
statusLabel.Text = ok and "Purchased!" or code
end)Reject non-finite numbers
Roblox warns that NaN and infinity have the number type and can undermine ordinary comparisons or calculations. Reject them explicitly before applying range or gameplay checks:
Code: Select all
local function isFiniteNumber(value: unknown): boolean
return typeof(value) == "number"
and value == value
and math.abs(value) < math.huge
end
local function isValidQuantity(value: unknown): boolean
return isFiniteNumber(value)
and value % 1 == 0
and value >= 1
and value <= 10
endCombat requests
Do not accept client-decided damage. For weapon requests, Roblox recommends server checks for plausible shot positions, obstacles, firing cadence, server-tracked ammunition, team rules, target life state, and weapon or player state. Damage and hit approval should come from server-owned definitions and server validation rather than a number supplied by the client.
Rejections and enforcement
Reject invalid requests before they change gameplay state. Roblox recommends preventing harm first, treating individual heuristics as signals rather than definitive proof, and using proportional responses that account for possible false positives.
Checklist
Before shipping a client-to-server remote, confirm that its handler:
- Validates every client argument before use.
- Checks context and permissions on the server.
- Resolves identifiers through server-owned data.
- Rejects malformed, oversized, non-finite, and out-of-range values.
- Enforces an appropriate server-side rate limit.
- Keeps prices, rewards, damage, ownership, and cooldown state authoritative on the server.
- Rejects invalid requests without applying their requested gameplay effects.