Use this file to discover all available pages before exploring further.
The fundamental rule of Roblox security is: the client is enemy territory. Every LocalScript, every value in ReplicatedStorage, and every RemoteEvent argument is accessible and modifiable by anyone running exploit software. A fully server-authoritative architecture is not optional for competitive or economy-driven games — it is the minimum viable security posture.
The Golden Rule: Never trust the client. Treat every RemoteEvent:FireServer() call as an HTTP request from an anonymous stranger. Validate everything. The client decides what to display; the server decides what actually happens.
Place this in ServerScriptService/Modules/RemoteValidator.luau:
local RemoteValidator = {}-- Type Checking --type TypeSpec = string | (value: any) -> booleanfunction RemoteValidator.checkType(value: any, expected: TypeSpec): boolean if type(expected) == "function" then return expected(value) end return typeof(value) == expectedendfunction RemoteValidator.validateArgs( args: { any }, schema: { { name: string, type: TypeSpec, optional: boolean? } }): (boolean, string?) for i, spec in schema do local value = args[i] if value == nil then if not spec.optional then return false, `Missing required argument: {spec.name}` end continue end if not RemoteValidator.checkType(value, spec.type) then return false, `Invalid type for {spec.name}: expected {tostring(spec.type)}, got {typeof(value)}` end end -- Reject extra arguments beyond the schema if #args > #schema then return false, `Too many arguments: expected {#schema}, got {#args}` end return true, nilend-- Range Checking --function RemoteValidator.checkRange(value: number, min: number, max: number): boolean return type(value) == "number" and value == value -- NaN check and value >= min and value <= maxendfunction RemoteValidator.checkIntegerRange(value: number, min: number, max: number): boolean return RemoteValidator.checkRange(value, min, max) and math.floor(value) == valueend-- Cooldown Tracking --local cooldowns: { [Player]: { [string]: number } } = {}function RemoteValidator.checkCooldown(player: Player, action: string, cooldownSeconds: number): boolean local now = os.clock() local playerCooldowns = cooldowns[player] if not playerCooldowns then playerCooldowns = {} cooldowns[player] = playerCooldowns end local lastUsed = playerCooldowns[action] if lastUsed and (now - lastUsed) < cooldownSeconds then return false end playerCooldowns[action] = now return trueendfunction RemoteValidator.clearPlayerCooldowns(player: Player) cooldowns[player] = nilend-- Existence and Ownership Checks --function RemoteValidator.characterAlive(player: Player): boolean local character = player.Character if not character then return false end local humanoid = character:FindFirstChildOfClass("Humanoid") if not humanoid then return false end return humanoid.Health > 0endfunction RemoteValidator.playerOwnsItem(player: Player, itemId: string, inventoryFolder: Folder?): boolean local folder = inventoryFolder or player:FindFirstChild("Inventory") :: Folder? if not folder then return false end return folder:FindFirstChild(itemId) ~= nilend-- Distance Check --function RemoteValidator.playerWithinRange(player: Player, targetPos: Vector3, maxDistance: number): boolean local character = player.Character if not character then return false end local root = character:FindFirstChild("HumanoidRootPart") if not root then return false end return (root.Position - targetPos).Magnitude <= maxDistanceend-- Cleanup --game:GetService("Players").PlayerRemoving:Connect(function(player) RemoteValidator.clearPlayerCooldowns(player)end)return RemoteValidator
-- ServerScriptService/RemoteHandlers/DamageHandler.server.luaulocal Validator = require(ServerScriptService.Modules.RemoteValidator)local MAX_DAMAGE = 50local DAMAGE_COOLDOWN = 0.5 -- secondslocal ATTACK_RANGE = 15 -- studslocal ARG_SCHEMA = { { name = "targetPlayer", type = "Instance" }, { name = "damage", type = "number" },}DamageRemote.OnServerEvent:Connect(function(player: Player, ...: any) local args = { ... } -- 1. Validate argument types local valid, err = Validator.validateArgs(args, ARG_SCHEMA) if not valid then warn(`[DamageHandler] {player.Name}: {err}`) return end local targetPlayer: Player = args[1] local damage: number = args[2] -- 2. Validate the target is actually a Player if not targetPlayer:IsA("Player") then return end -- 3. Validate damage range if not Validator.checkIntegerRange(damage, 1, MAX_DAMAGE) then warn(`[DamageHandler] {player.Name}: damage out of range ({damage})`) return end -- 4. Cooldown check if not Validator.checkCooldown(player, "DealDamage", DAMAGE_COOLDOWN) then return end -- 5. Verify attacker is alive if not Validator.characterAlive(player) then return end -- 6. Verify target is alive if not Validator.characterAlive(targetPlayer) then return end -- 7. Range check local targetRoot = targetPlayer.Character and targetPlayer.Character:FindFirstChild("HumanoidRootPart") if not targetRoot then return end if not Validator.playerWithinRange(player, targetRoot.Position, ATTACK_RANGE) then warn(`[DamageHandler] {player.Name}: target out of range`) return end -- 8. Weapon authorization local character = player.Character local weapon = character and character:FindFirstChildOfClass("Tool") if not weapon or not weapon:GetAttribute("CanDealDamage") then warn(`[DamageHandler] {player.Name}: no valid weapon equipped`) return end -- 9. Server calculates actual damage from weapon config, not client value local serverDamage = math.min(damage, weapon:GetAttribute("MaxDamage") or MAX_DAMAGE) -- 10. Apply damage local targetHumanoid = targetPlayer.Character:FindFirstChildOfClass("Humanoid") if targetHumanoid then targetHumanoid:TakeDamage(serverDamage) endend)
Client-side currency manipulation is the most common exploit in Roblox games. If a client can tell the server how much money to add, the game economy is broken.
❌ Wrong — Client sends amount
✅ Right — Server calculates reward
-- Client fires with amount (attacker sends 999999)AddGoldRemote:FireServer(999999)-- Server blindly applies itAddGoldRemote.OnServerEvent:Connect(function(player, amount) player.leaderstats.Gold.Value += amountend)
-- Client fires only the action (quest completion)QuestCompleteRemote:FireServer("quest_defeat_golem")-- Server validates and looks up the reward from its own dataQuestCompleteRemote.OnServerEvent:Connect(function(player, questId) if typeof(questId) ~= "string" then return end local questData = PlayerQuestData[player] if not questData or not questData[questId] then return end if questData[questId].completed then return end -- already claimed local questConfig = QuestDatabase[questId] if not questConfig then return end -- Server awards the reward from server-owned config questData[questId].completed = true player.leaderstats.Gold.Value += questConfig.Rewardend)
The same principle applies to all economy actions: purchases, drops, trades, and achievements. The server owns the reward values. The client only sends intent.
-- ServerScriptService/Modules/RateLimiter.luaulocal Players = game:GetService("Players")local RateLimiter = {}export type RateLimitConfig = { maxRequests: number, windowSeconds: number, cooldownSeconds: number?, kickAfterViolations: number?, kickMessage: string?,}type PlayerActionData = { timestamps: { number }, violations: number, cooldownUntil: number,}local tracking: { [Player]: { [string]: PlayerActionData } } = {}local DEFAULT_KICK_MESSAGE = "Too many requests. Please try again later."function RateLimiter.check(player: Player, action: string, config: RateLimitConfig): boolean local now = os.clock() if not tracking[player] then tracking[player] = {} end local playerActions = tracking[player] if not playerActions[action] then playerActions[action] = { timestamps = {}, violations = 0, cooldownUntil = 0 } end local data = playerActions[action] if now < data.cooldownUntil then return false end -- Prune old timestamps local windowStart = now - config.windowSeconds local pruned = {} for _, ts in data.timestamps do if ts > windowStart then table.insert(pruned, ts) end end data.timestamps = pruned if #data.timestamps >= config.maxRequests then data.violations += 1 if config.cooldownSeconds then data.cooldownUntil = now + config.cooldownSeconds end if config.kickAfterViolations and data.violations >= config.kickAfterViolations then local message = config.kickMessage or DEFAULT_KICK_MESSAGE warn(`[RateLimiter] Kicking {player.Name}: {data.violations} violations on "{action}"`) task.defer(function() player:Kick(message) end) else warn(`[RateLimiter] {player.Name}: rate limited on "{action}" (violation #{data.violations})`) end return false end table.insert(data.timestamps, now) return trueendfunction RateLimiter.wrapRemote( remote: RemoteEvent, config: RateLimitConfig, handler: (player: Player, ...any) -> ()) remote.OnServerEvent:Connect(function(player: Player, ...: any) if not RateLimiter.check(player, remote.Name, config) then return end handler(player, ...) end)endPlayers.PlayerRemoving:Connect(function(player) tracking[player] = nil end)return RateLimiter
-- ServerScriptService/Security/MovementValidator.server.luaulocal Players = game:GetService("Players")local RunService = game:GetService("RunService")local MAX_SPEED = 50 -- studs per secondlocal MAX_VERTICAL_SPEED = 100 -- studs per secondlocal VIOLATION_THRESHOLD = 5local CHECK_INTERVAL = 0.5local playerData: { [Player]: { lastPosition: Vector3, lastCheck: number, violations: number,} } = {}Players.PlayerAdded:Connect(function(player) player.CharacterAdded:Connect(function(character) local root = character:WaitForChild("HumanoidRootPart") playerData[player] = { lastPosition = root.Position, lastCheck = os.clock(), violations = 0, } end)end)Players.PlayerRemoving:Connect(function(player) playerData[player] = nilend)RunService.Heartbeat:Connect(function() local now = os.clock() for player, data in playerData do if (now - data.lastCheck) < CHECK_INTERVAL then continue end local character = player.Character if not character then continue end local root = character:FindFirstChild("HumanoidRootPart") if not root then continue end local dt = now - data.lastCheck local displacement = root.Position - data.lastPosition local horizontalSpeed = Vector3.new(displacement.X, 0, displacement.Z).Magnitude / dt local verticalSpeed = math.abs(displacement.Y) / dt if horizontalSpeed > MAX_SPEED or verticalSpeed > MAX_VERTICAL_SPEED then data.violations += 1 warn(`[MovementValidator] {player.Name}: speed violation #{data.violations} (h={math.floor(horizontalSpeed)}, v={math.floor(verticalSpeed)})`) if data.violations >= VIOLATION_THRESHOLD then -- Teleport back to last valid position root.CFrame = CFrame.new(data.lastPosition) -- Or kick for persistent abuse: -- player:Kick("Movement anomaly detected.") end else data.violations = math.max(0, data.violations - 1) data.lastPosition = root.Position end data.lastCheck = now endend)
local function isGrounded(character: Model): boolean local root = character:FindFirstChild("HumanoidRootPart") if not root then return false end local params = RaycastParams.new() params.FilterDescendantsInstances = { character } params.FilterType = Enum.RaycastFilterType.Exclude local result = workspace:Raycast(root.Position, Vector3.new(0, -10, 0), params) return result ~= nilendlocal airTime: { [Player]: number } = {}local MAX_AIR_TIME = 5 -- seconds before flaggingRunService.Heartbeat:Connect(function(dt) for _, player in Players:GetPlayers() do local character = player.Character if not character then continue end if isGrounded(character) then airTime[player] = 0 else airTime[player] = (airTime[player] or 0) + dt if airTime[player] > MAX_AIR_TIME then warn(`[FlyDetect] {player.Name}: airborne for {math.floor(airTime[player])}s`) -- Take action: teleport to ground, kick, etc. end end endend)
local claimedRewards: { [Player]: { [string]: boolean } } = {}local function claimReward(player: Player, rewardId: string): boolean local claimed = claimedRewards[player] if not claimed then claimed = {} claimedRewards[player] = claimed end -- Idempotency: already claimed if claimed[rewardId] then return false end -- Mark as claimed BEFORE granting (prevents race condition) claimed[rewardId] = true local reward = RewardDatabase[rewardId] if reward then grantItem(player, reward.ItemId) end return trueend
local RESTRICTED_ZONE_CENTER = Vector3.new(100, 5, 200)local RESTRICTED_ZONE_RADIUS = 30RunService.Heartbeat:Connect(function() for _, player in Players:GetPlayers() do local root = player.Character and player.Character:FindFirstChild("HumanoidRootPart") if not root then continue end local distance = (root.Position - RESTRICTED_ZONE_CENTER).Magnitude if distance < RESTRICTED_ZONE_RADIUS then if not player:GetAttribute("HasZoneAccess") then root.CFrame = CFrame.new( RESTRICTED_ZONE_CENTER + Vector3.new(RESTRICTED_ZONE_RADIUS + 5, 0, 0) ) warn(`[Noclip] {player.Name}: entered restricted zone without access`) end end endend)
-- BAD: broadcasting full player data to everyoneUpdateRemote:FireAllClients({ name = player.Name, gold = player.leaderstats.Gold.Value, secretInventory = getFullInventory(player), adminLevel = player:GetAttribute("AdminLevel"),})-- GOOD: send only what this specific client needsUpdateRemote:FireClient(player, { gold = player.leaderstats.Gold.Value,})
Never broadcast other players’ currency balances, inventory contents, admin status, or any internal session tokens.
No. The client must have the key, so the exploiter does too.
Hiding remotes in nested folders
No. game:GetDescendants() finds everything.
Server-side validation
Yes. This is the only real security.
Obfuscation may slow down casual script kiddies, but it must never be relied upon. If your game breaks when someone reads your client code, your security model is wrong.
-- BAD: client tells server where to teleport themMoveRemote.OnServerEvent:Connect(function(player, position) player.Character.HumanoidRootPart.CFrame = CFrame.new(position)end)-- GOOD: server reads position directly from the character-- and validates against expected movement speed
Exposing admin remotes without server-side auth
-- BAD: client-side check is bypassable — exploiter fires the remote directlyAdminKickRemote.OnServerEvent:Connect(function(player, targetName) local target = Players:FindFirstChild(targetName) if target then target:Kick("Kicked by admin") endend)-- GOOD: server validates admin status using a server-owned listlocal ADMIN_LIST = { [12345678] = true } -- UserId list in ServerScriptServiceAdminKickRemote.OnServerEvent:Connect(function(player, targetName) if not ADMIN_LIST[player.UserId] then warn(`[Admin] Unauthorized attempt by {player.Name} ({player.UserId})`) return end if typeof(targetName) ~= "string" then return end local target = Players:FindFirstChild(targetName) if target then target:Kick("Kicked by admin") endend)
Security through obscurity
-- BAD: "They'll never guess the remote name"local remote = Instance.new("RemoteEvent")remote.Name = "x7f2k9_internal_do_not_use"remote.Parent = ReplicatedStorage-- RemoteSpy reveals it instantly regardless of the name.-- Validation is what protects the server, not naming.