How to stop daily rewards and codes being claimed twice

Daily rewards, promo codes and quest prizes that pay out again on every call or rejoin. How to record claims in saved data before paying.

criticalLuaudit check: untracked-claims

What it looks like

A remote pays out a reward meant to be claimed once, once per period or only after finishing something: a daily reward, a promo code, a quest or achievement reward, a starter pack, a round prize. The server pays without recording the claim, records it somewhere that's lost when the player rejoins, or never checks that the player earned it.

Luau
remote.OnServerEvent:Connect(function(player)
	profile.Coins += 100
end)

How to detect it

  • List every remote that grants currency, items or XP without taking anything in return.
  • For each one, find where the server records that the reward was claimed, and where it checks that record before paying. No record means the reward can be claimed as often as the remote can be fired.
  • Check where the record lives. A table in a script or an attribute lasts only for this server. A reward meant once per account or once per day needs its record in the saved profile.
  • Check that the record and the lookup use the same key. A code checked with string.upper(code) but recorded under the raw code can be claimed once per spelling.
  • Timed rewards must use the server's clock, such as os.time(), never a time sent by the client.
  • Rewards for finishing something, like a quest, a round or a course, need server-side progress that says it's done. If the client sends a value claiming it's done, report that under Server trusts values the client decides instead.

What an exploiter could do

They claim the same reward again and again, by firing the remote repeatedly, rejoining or moving servers, or they claim rewards for things they never did. Free currency and items unbalance trading, shops and leaderboards for everyone.

How to fix it

  • Keep claims in the saved profile, such as profile.Claimed[key] = true or profile.LastDaily = os.time().
  • Check the record, write it, then pay, with nothing that yields in between.
  • Normalise ids once, then use the same value to look up the reward and record the claim.
  • Pay progress rewards only when progress the server tracked says the goal is met.

Vulnerable example

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

local quests: { [string]: { goal: number, reward: number } } = {
	CollectFlowers = { goal = 20, reward = 200 },
	DefeatSlimes = { goal = 10, reward = 350 },
}

ReplicatedStorage.Remotes.ClaimQuest.OnServerEvent:Connect(function(player: Player, questId: unknown)
	if typeof(questId) ~= "string" then
		return
	end
	local quest = quests[questId]
	local profile = PlayerData.get(player)
	if not quest or not profile then
		return
	end
	profile.Coins += quest.reward
end)

Fixed example

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

local quests: { [string]: { goal: number, reward: number } } = {
	CollectFlowers = { goal = 20, reward = 200 },
	DefeatSlimes = { goal = 10, reward = 350 },
}

ReplicatedStorage.Remotes.ClaimQuest.OnServerEvent:Connect(function(player: Player, questId: unknown)
	if typeof(questId) ~= "string" then
		return
	end
	local quest = quests[questId]
	local profile = PlayerData.get(player)
	if not quest or not profile then
		return
	end
	local key = "Quest:" .. questId
	if profile.Claimed[key] or (profile.Progress[questId] or 0) < quest.goal then
		return
	end
	profile.Claimed[key] = true
	profile.Coins += quest.reward
end)

Common false positives

  • Rewards meant to reset with the server, such as chests that respawn every few minutes, can be tracked in memory.
  • A reward that's destroyed or moved when claimed, like a coin part removed on pickup, is its own record, as long as the handler checks it's still there first.
  • A record kept by a helper module, such as a quest service, counts. Read it before reporting.