How to secure ProximityPrompts and ClickDetectors on the server

Exploiters can fire prompt and click events from anywhere, ignoring Enabled and MaxActivationDistance. How to check distance, state and timing on the server.

criticalLuaudit check: unchecked-prompts

What it looks like

A ProximityPrompt, ClickDetector or DragDetector hands out something, and the server handler trusts the object's own settings to limit who can use it and when: Enabled, MaxActivationDistance, RequiresLineOfSight or HoldDuration. Roblox's security guide says exploiters can fire these events from any distance, at any time, often ignoring those settings.

Luau
chest.ClickDetector.MouseClick:Connect(function(player)
	giveReward(player)
end)

How to detect it

  • Find every ProximityPrompt, ClickDetector and DragDetector connection in server code and what each one gives, changes or costs.
  • ClickDetector events have no server-side checks at all. MaxActivationDistance, a value of 0 or a parent that can't be clicked doesn't stop them. Look for a distance check against the player's character and any state the action needs.
  • For ProximityPrompt, only Triggered has a server-side distance check. PromptButtonHoldBegan, PromptButtonHoldEnded and TriggerEnded can be sent by any client from anywhere. Enabled set to false on the server doesn't stop any of them, and the client controls how long it actually holds.
  • Look for actions that need a hold or a wait, such as lockpicking or channelling. The server must time them itself, from its own record of when the hold began, rather than trusting HoldDuration.
  • For DragDetector, only Enabled and PermissionPolicy are checked for drag events. Click events inherited from ClickDetector aren't checked.
  • If the prompt or detector sits on an unanchored part, the player can take network ownership and bring the part to their character, so the distance check must not rely on that part's position alone. See Rewards decided by parts a client simulates.
  • A disabled prompt or detector that the game switches on later, such as a chest that unlocks after a boss, needs a server-side record of whether it's open. Checking Enabled on the server is enough only when the server is the only thing that changes it.
  • Rate-limit anything that gives currency or items, per player, on the server.

What an exploiter could do

They trigger every prompt and click detector in the map from wherever they stand: opening every chest, collecting every pickup, buying from every shop, starting locked events and finishing timed actions instantly. Disabled prompts for content that isn't open yet work too.

How to fix it

  • In every handler, check the player's character exists, is alive and is close enough to the object, measured on the server from anchored parts.
  • Keep the state that decides whether the object can be used, such as unlocked or cooling down, in a server table, and check it in the handler.
  • Time holds and channels on the server from the moment the server saw them begin, and add a per-player cooldown.

Vulnerable example

Vulnerable
local Workspace = game:GetService("Workspace")
local PlayerData = require(script.Parent.PlayerData)

local reward = 50

for _, chest in Workspace:WaitForChild("Chests"):GetChildren() do
	local detector = chest:FindFirstChildOfClass("ClickDetector")
	if detector then
		detector.MaxActivationDistance = 10
		detector.MouseClick:Connect(function(player: Player)
			local profile = PlayerData.get(player)
			local key = "Chest:" .. chest.Name
			if profile and not profile.Claimed[key] then
				profile.Claimed[key] = true
				profile.Coins += reward
			end
		end)
	end
end

Fixed example

Fixed
local Workspace = game:GetService("Workspace")
local PlayerData = require(script.Parent.PlayerData)

local reward = 50
local reach = 12

for _, chest in Workspace:WaitForChild("Chests"):GetChildren() do
	local detector = chest:FindFirstChildOfClass("ClickDetector")
	if detector and chest:IsA("BasePart") and chest.Anchored then
		detector.MaxActivationDistance = 10
		detector.MouseClick:Connect(function(player: Player)
			local root = player.Character and player.Character:FindFirstChild("HumanoidRootPart")
			local humanoid = player.Character and player.Character:FindFirstChildOfClass("Humanoid")
			if not root or not root:IsA("BasePart") or not humanoid or humanoid.Health <= 0 then
				return
			end
			if (root.Position - chest.Position).Magnitude > reach then
				return
			end
			local profile = PlayerData.get(player)
			local key = "Chest:" .. chest.Name
			if profile and not profile.Claimed[key] then
				profile.Claimed[key] = true
				profile.Coins += reward
			end
		end)
	end
end

Common false positives

  • A prompt or detector whose action only affects the player who used it and gives them nothing, such as opening a door that closes again or playing a sound, needs no distance check.
  • ProximityPrompt.Triggered has a server-side distance check, so a prompt on an anchored part doesn't need its own. It still needs checks for any game state the action depends on. Roblox's guide warns that a prompt on an unanchored part can be pulled to the player's character, so that one needs a distance check too; see Rewards decided by parts a client simulates.
  • Handlers that check distance from the character to an anchored part on the server, like the fixed example, are the fix.