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.
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 rawcodecan 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] = trueorprofile.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.