How to Secure Roblox RemoteEvents with Validation, Rate Limits, and Server Authority
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:
  • 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.
Roblox's security guidance uses an in-game shop as an example: the server should know the real price, the player's balance, and the player's distance from the shop before approving a purchase.

Recommended layout

Code: Select all

ReplicatedStorage
└── Remotes
    ├── BuyItem (RemoteEvent)
    └── PurchaseResult (RemoteEvent)
ServerScriptService
├── RateLimiter (ModuleScript)
└── SecureShop (Script)
ServerStorage
└── Items
    └── BronzeSword (Tool)
Workspace
└── ShopCounter (Part)
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:

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

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)
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:

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)
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:

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
end
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:
  • 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.
Editorial reference: TOG-EDITORIAL-82