How to write cooldowns a client can't reset

Cooldowns kept per character, per argument or timed with a client's clock don't hold. How to key them by Player and measure time on the server.

warningLuaudit check: resettable-cooldown

What it looks like

The server has a cooldown, but it's keyed by or measured with something the client controls, so the client can start it over whenever it likes. Common shapes are a cooldown kept per character, per argument the client sends, or worked out from a time the client sends.

Luau
local lastUse: { [Model]: number } = {}

remote.OnServerEvent:Connect(function(player)
	local character = player.Character
	if character and os.clock() - (lastUse[character] or 0) >= 5 then
		lastUse[character] = os.clock()
	end
end)

How to detect it

  • For each cooldown, find the table key and the clock.
  • The key should be the Player or their UserId. A key from the client, such as an item name, a tool or any other argument, lets them use a new key each time. A character key resets whenever they respawn, which players can usually do from the menu.
  • The clock should be the server's own os.clock() or os.time(). A time sent by the client is whatever they say it is.
  • Check where the cooldown entry is cleared. Clearing it on respawn, on unequip or when a value the client sends changes gives the client a reset button.
  • Cooldowns that should last longer than a session, such as daily rewards, belong in saved data. That's Reward claims the server doesn't track in server-validation.

What an exploiter could do

The cooldown doesn't hold, so the remote can be fired as fast as the client likes. Whatever the cooldown was protecting against comes back: lag from repeated effects or new parts, or a reward meant to be slow paid out over and over.

How to fix it

  • Key every cooldown by Player, and clear it in Players.PlayerRemoving.
  • Measure time on the server with os.clock(). Never use a time the client sends.
  • If one player's actions need separate cooldowns, like one per ability, key by player first and then by a server-checked ability name.

Vulnerable example

Vulnerable
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local cooldown = 5
local lastBlast: { [Model]: number } = {}

ReplicatedStorage.Remotes.Shockwave.OnServerEvent:Connect(function(player: Player)
	local character = player.Character
	local root = character and character:FindFirstChild("HumanoidRootPart")
	if not character or not root or not root:IsA("BasePart") then
		return
	end
	local now = os.clock()
	if now - (lastBlast[character] or 0) < cooldown then
		return
	end
	lastBlast[character] = now
	local blast = Instance.new("Explosion")
	blast.BlastPressure = 0
	blast.DestroyJointRadiusPercent = 0
	blast.Position = root.Position
	blast.Parent = workspace
end)

Fixed example

Fixed
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local cooldown = 5
local lastBlast: { [Player]: number } = {}

ReplicatedStorage.Remotes.Shockwave.OnServerEvent:Connect(function(player: Player)
	local character = player.Character
	local root = character and character:FindFirstChild("HumanoidRootPart")
	if not root or not root:IsA("BasePart") then
		return
	end
	local now = os.clock()
	if now - (lastBlast[player] or 0) < cooldown then
		return
	end
	lastBlast[player] = now
	local blast = Instance.new("Explosion")
	blast.BlastPressure = 0
	blast.DestroyJointRadiusPercent = 0
	blast.Position = root.Position
	blast.Parent = workspace
end)

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

Common false positives

  • Cooldowns that are meant to reset on respawn, like an ability that recharges when you die, are fine when respawning itself is slow or limited. Check how quickly a player can respawn.
  • Per-ability cooldowns keyed by player and an allowlisted ability name are fine.
  • A client timestamp used only to order messages, like the newest position wins, isn't a cooldown.