Back to Blog

Roblox Anti-Exploit Guide: How to Secure Your RemoteEvents

By Arya Harshwardhan•May 14, 2026
Roblox Anti-Exploit Guide: How to Secure Your RemoteEvents

TL;DR - Key Takeaways

  • • Assume every client is hostile: it can fire any remote, with any arguments, as often as it likes
  • • Validate shape, existence, entitlement, and state on the server before acting on any request
  • • The client reports intent; the server decides outcomes. Never accept damage, prices, or rewards from a client
  • • Rate-limit every remote per player; a token bucket tolerates honest bursts and stops floods
  • • Client-side anti-cheat is a speed bump and a signal, never a security control
  • • Correct and log first, ban on high-confidence evidence; false positives cost you real players

When a client brings us a game that is being exploited, the conversation usually starts with a request for an anti-cheat: something that detects the cheaters and bans them. Occasionally that is part of the answer. Far more often, the real fix is removing authority the client should never have had in the first place.

Exploits on Roblox are rarely clever. They are someone noticing that a RemoteEvent called GiveReward accepts an amount, or that a shop checks your balance on the client. This guide is about closing those doors systematically, so you are not playing detection whack-a-mole after launch.

What an Exploiter Can Actually Do

Good security starts with an accurate threat model. Roblox has enforced server-side filtering for every game for years, so a client cannot simply change a value and have it replicate to everyone. That still leaves a lot. A player running a modified client can:

  • Fire any RemoteEvent or RemoteFunction with any arguments, of any type, at any rate, including ones your UI never exposes.
  • Read everything sent to the client: every LocalScript, every ModuleScript in ReplicatedStorage, and every instance in Workspace. Anything secret, such as admin lists, unreleased item data, or webhook URLs, must never live there.
  • Move their own character freely. Clients own their character's physics, so teleporting, flying, and speed changes replicate to the server unless something checks them.
  • Delete or rewrite any LocalScript, including the anti-cheat you wrote to catch them.

What they cannot do is equally important. They cannot read or modify server Scripts, anything in ServerStorage or ServerScriptService, or values that only exist on the server. That is the whole basis of a secure game: anything that matters lives on the server, and the server decides. If that model is new to you, start with our client-server architecture guide and come back.

Roblox's Server Authority model, in beta at the time of writing, moves character movement and physics validation into the engine for games that adopt it. It is a welcome change for competitive games. It validates physics, though, not your game rules: it will not stop a remote that grants currency to whoever asks.

Validate Every Argument, Every Time

Every argument a remote receives is untrusted input. Your type annotations do not help here: an exploiter can send a string where you expect a number, a table where you expect an Instance, or values chosen specifically to break arithmetic. Two values deserve special attention in Luau. NaN fails every comparison, so amount > 100 is false and a range check can wave it straight through. And math.huge passes any minimum check you write.

Rather than re-deriving these checks in every handler, put them in one small module:

-- ServerScriptService/Modules/Validate (ModuleScript)
local Validate = {}

function Validate.finiteNumber(value: any, min: number, max: number): boolean
    return typeof(value) == "number"
        and value == value                    -- rejects NaN
        and value >= min and value <= max   -- rejects infinity and out-of-range
end

function Validate.integer(value: any, min: number, max: number): boolean
    return Validate.finiteNumber(value, min, max) and value % 1 == 0
end

function Validate.shortString(value: any, maxLength: number): boolean
    return typeof(value) == "string"
        and #value <= maxLength
        and utf8.len(value) ~= nil          -- rejects invalid UTF-8
end

function Validate.vector3(value: any, maxMagnitude: number): boolean
    if typeof(value) ~= "Vector3" then
        return false
    end
    local magnitude = value.Magnitude
    return magnitude == magnitude and magnitude <= maxMagnitude
end

function Validate.instanceIn(value: any, className: string, container: Instance): boolean
    return typeof(value) == "Instance"
        and value:IsA(className)
        and value:IsDescendantOf(container)
end

return Validate

Type checks are only the first layer. A useful order to validate in is shape, existence, entitlement, state: is the argument the right type, does the thing it refers to exist, is this player allowed to act on it, and is the game in a state where the action makes sense right now. Here is a tower placement handler that checks all four:

PlaceTower.OnServerEvent:Connect(function(player, towerId, position)
    -- Shape
    if not Validate.shortString(towerId, 32) then return end
    if not Validate.vector3(position, 5000) then return end

    -- Existence
    local definition = TowerCatalogue[towerId]
    if not definition then return end

    -- Entitlement: owns this tower and can afford to place it
    if not Inventory.owns(player, towerId) then return end
    if Wallet.getBalance(player, "coins") < definition.cost then return end

    -- State: a round is running and the spot is inside this player's build zone
    if not Round.isActive() then return end
    if not BuildZones.contains(player, position) then return end

    Wallet.spend(player, "coins", definition.cost)
    Towers.spawn(player, towerId, position)
end)

Notice what the handler does not do: it never uses a price, a damage value, or a range supplied by the client. The client chose which tower and where. Everything else comes from server-side data.

The Client Reports Intent; the Server Decides Outcomes

The most common exploitable pattern we find in inherited code is a remote that carries an outcome rather than an intent. DealDamage:FireServer(target, 50) asks the server to trust a number. AwardCoins:FireServer(100) is an open invitation. The fix is to redesign the remote so the client only says what it tried to do:

-- BAD: the client decides the outcome
DealDamage.OnServerEvent:Connect(function(player, target, amount)
    target.Humanoid:TakeDamage(amount)
end)

-- GOOD: the client says "I swung at this target"; the server decides what happened
local SWING_COOLDOWN = 0.6
local MAX_REACH = 9  -- studs, including headroom for latency
local lastSwingAt: {[Player]: number} = {}

SwingWeapon.OnServerEvent:Connect(function(player, target)
    local now = os.clock()
    if now - (lastSwingAt[player] or 0) < SWING_COOLDOWN then return end
    lastSwingAt[player] = now

    if not Validate.instanceIn(target, "Model", workspace) then return end
    local targetHumanoid = target:FindFirstChildOfClass("Humanoid")
    local targetRoot = target:FindFirstChild("HumanoidRootPart")
    local character = player.Character
    local attackerRoot = character and character:FindFirstChild("HumanoidRootPart")
    if not (targetHumanoid and targetRoot and attackerRoot) then return end
    if targetHumanoid.Health <= 0 then return end

    if (attackerRoot.Position - targetRoot.Position).Magnitude > MAX_REACH then return end

    local weapon = WeaponCatalogue[Loadout.getEquipped(player)]
    if weapon then
        targetHumanoid:TakeDamage(weapon.damage)  -- damage comes from server data
    end
end)

Players.PlayerRemoving:Connect(function(player)
    lastSwingAt[player] = nil
end)

The reach check includes deliberate headroom. On a laggy connection, the target the player saw is not quite where the server has it, and an exact check would reject honest hits. Tune the tolerance against real sessions: too tight feels broken to honest players, too loose leaves room for reach exploits. Shooters take this further with full hit validation, which we describe in our Skies case study.

Also notice the cleanup on PlayerRemoving. Per-player tables keyed by the Player object leak memory on long-running servers if they are never cleared, a problem covered in our performance optimisation guide.

Rate-Limit Every Remote

Even a perfectly validated remote can be abused through volume. Firing a valid purchase request a thousand times a second can expose race conditions, hammer your DataStore budget, or simply lag the server for everyone else. Every remote needs a rate limit.

A fixed cooldown is the obvious approach, but it punishes honest behaviour: a player clicking quickly, or a laggy connection delivering several queued requests at once, looks like spam. A token bucket handles this better. Each player gets a bucket of tokens that refills at a steady rate; each request spends one; an empty bucket means the request is dropped. Short bursts are absorbed, sustained floods are not.

-- ServerScriptService/Modules/RateLimiter (ModuleScript)
local Players = game:GetService("Players")

local RateLimiter = {}
RateLimiter.__index = RateLimiter

function RateLimiter.new(capacity: number, refillPerSecond: number)
    local self = setmetatable({}, RateLimiter)
    self.capacity = capacity
    self.refillPerSecond = refillPerSecond
    self.buckets = {}
    Players.PlayerRemoving:Connect(function(player)
        self.buckets[player] = nil
    end)
    return self
end

-- Returns true and spends a token if the request is allowed
function RateLimiter:allow(player: Player): boolean
    local now = os.clock()
    local bucket = self.buckets[player]
    if not bucket then
        bucket = { tokens = self.capacity, updatedAt = now }
        self.buckets[player] = bucket
    end
    local elapsed = now - bucket.updatedAt
    bucket.tokens = math.min(self.capacity, bucket.tokens + elapsed * self.refillPerSecond)
    bucket.updatedAt = now
    if bucket.tokens < 1 then
        return false
    end
    bucket.tokens -= 1
    return true
end

return RateLimiter
-- Bursts of up to 5 placements, then 2 per second sustained
local placeLimiter = RateLimiter.new(5, 2)

PlaceTower.OnServerEvent:Connect(function(player, towerId, position)
    if not placeLimiter:allow(player) then return end
    -- validation as above
end)

Create one limiter per remote, sized to how often a legitimate player could possibly fire it. A remote that should fire once per round can have a tiny bucket. A remote driven by continuous input needs a larger one.

Movement Checks Need Tolerance

Because clients own their character's physics, speed hacks and teleports replicate unless the server checks for them. Setting WalkSpeed on the server does not help on its own: the exploiter changes their local value, and their faster movement replicates anyway. What you can do is compare where a character is against where it could plausibly be.

local CHECK_INTERVAL = 0.5
local SPEED_TOLERANCE = 1.5      -- headroom for latency bursts and physics jitter
local VIOLATIONS_TO_FLAG = 4

-- [Player] = { lastPosition = Vector3?, violations = number, graceUntil = number }
local movementState = {}

-- Call this before any server-initiated teleport, dash, or knockback
local function grantMovementGrace(player: Player, seconds: number)
    local state = movementState[player]
    if state then
        state.graceUntil = os.clock() + seconds
        state.lastPosition = nil
    end
end

local function checkMovement(player: Player, state, now: number)
    local character = player.Character
    local root = character and character:FindFirstChild("HumanoidRootPart")
    local humanoid = character and character:FindFirstChildOfClass("Humanoid")
    if not (root and humanoid) or now < state.graceUntil then
        state.lastPosition = nil
        return
    end

    local current = root.Position
    local previous = state.lastPosition
    if previous then
        -- Horizontal only, so falling never counts as speeding
        local moved = Vector3.new(current.X - previous.X, 0, current.Z - previous.Z).Magnitude
        local allowed = humanoid.WalkSpeed * CHECK_INTERVAL * SPEED_TOLERANCE
        if moved > allowed then
            state.violations += 1
            character:PivotTo(CFrame.new(previous))  -- quietly undo the move
            if state.violations >= VIOLATIONS_TO_FLAG then
                ExploitLog.flag(player, "movement", { moved = moved, allowed = allowed })
            end
            return
        end
        state.violations = math.max(0, state.violations - 0.25)  -- forgive slowly
    end
    state.lastPosition = current
end

A loop calls checkMovement for each tracked player every CHECK_INTERVAL. Three design choices make this safe to run on real players. It uses the server's own WalkSpeed, which the exploiter cannot change. It corrects silently instead of kicking, so a false positive costs an honest player a small rubber-band rather than their session. And it only flags after repeated violations that decay over time, because one bad network burst is not evidence of anything.

Every legitimate way your game moves a player faster than walking, such as teleport pads, dashes, vehicles, knockback, and conveyors, must call grantMovementGrace first. Forgetting one is the most common source of false positives in movement checks.

Client-Side Detection and Honeypots

Client-side anti-cheat scripts are popular because they feel proactive. Their limitation is fundamental: they run on the exploiter's machine, and the exploiter can delete them. Treat anything a LocalScript reports as a signal worth logging, never as a control your security depends on.

A more reliable signal costs one line of thought: a honeypot remote. Create a RemoteEvent with a tempting name that no legitimate code ever fires. Exploit tools that enumerate remotes will find it, and anyone who fires it has told you something with near-certainty.

-- No legitimate client code ever fires this remote
local honeypot = Instance.new("RemoteEvent")
honeypot.Name = "AdminGrantCurrency"
honeypot.Parent = ReplicatedStorage.Remotes

honeypot.OnServerEvent:Connect(function(player)
    ExploitLog.flag(player, "honeypot", {})
end)

Honeypots will not catch a careful exploiter, but they catch a large share of the casual ones at essentially zero risk of a false positive.

Responding: Correct, Log, Then Act

How you respond matters as much as how you detect. Banning on a heuristic will eventually ban an honest player on a bad connection, and they will tell their friends. I sort signals into two kinds:

  • Impossible: a honeypot fired, or arguments a legitimate client can never produce, such as a NaN position or an item id your UI never sends. These are high confidence and can justify action on their own.
  • Improbable: movement slightly too fast, hits at the edge of reach, an unusually lucky drop streak. Correct these silently, log them, and act only when the evidence accumulates.

When you do act, Roblox's ban API, Players:BanAsync(), applies bans across every server of your experience and can extend them to suspected alternate accounts, so you no longer need a hand-built ban list in a DataStore. Keep a private reason on every ban recording which signals triggered it. You will need it when someone appeals.

For economy exploits, removing the gains matters more than removing the player. If someone has duplicated items, banning them leaves the duplicates circulating. This is where reliable data infrastructure pays off; our DataStore best practices guide covers the session locking that prevents the most common duplication bugs in the first place.

A Review Checklist for Every Remote

Before any remote ships, we answer these questions about it. If one has no answer, the remote is not finished.

  • What happens if it receives the wrong type, NaN, an enormous number, or a 100,000-character string?
  • What happens if it is fired 100 times a second?
  • Does any argument carry an outcome, such as a price, damage, reward, or position, that the server trusts?
  • Does it check that this player is entitled to this action, on this object, right now?
  • If it is a RemoteFunction, can a slow or malicious caller hold up anything important while the server works on its request?
  • Is anything it relies on readable by the client that should not be?

Security Is an Architecture, Not a Script

There is no anti-cheat you can drop into a game that was designed to trust the client. The games that hold up against exploiters are the ones where the server owns every decision that matters and the client is a well-behaved input device, so that a modified client has nothing to gain. Detection then becomes a backstop rather than the whole strategy.

We build every game this way, and security review of inherited codebases is a regular part of our Luau scripting services. If your game is being exploited, or you want to know how exposed it is before launch, get in touch with our team. For the most common security mistakes we see in newer codebases, read 5 Common Mistakes New Roblox Developers Make.

Arya Harshwardhan

Lead Developer, Santoz Studios

Arya Harshwardhan is Lead Developer at Santoz Studios, where he owns technical architecture, performance, and exploit resistance across the studio's projects. He specialises in server-authoritative Luau systems and in auditing inherited Roblox codebases before new work is built on top of them.