How to stop MemoryStore queue items paying out twice

A queue item that isn't removed in time becomes visible again and gets processed twice. How to record each item's id so gifts and prizes are only granted once.

criticalLuaudit check: queue-items-processed-twice

What it looks like

A MemoryStoreQueue carries things that pay out, such as cross-server gifts, mail, trade proceeds or rewards from a live event. The server reads an item, grants it, then removes it, but the grant isn't recorded anywhere, so an item that's read again is granted again.

Luau
local items, id = queue:ReadAsync(10, false, 30)
for _, gift in items do
	profile.Coins += gift
end
pcall(queue.RemoveAsync, queue, id)

How to detect it

  • Find every MemoryStoreService:GetQueue and every ReadAsync loop, and what the server does with each item.
  • Roblox's queue guide explains that a read item is only hidden for the queue's invisibility timeout, 30 seconds by default. If RemoveAsync isn't called in time, or fails, the item becomes visible again and the next read, on any server, processes it again.
  • Look for anything that can delay or skip the remove: yields between the read and the remove, such as data store calls, task.wait or web calls; a RemoveAsync outside pcall or with its failure ignored; an error in the grant that stops the loop before the remove.
  • Check whether the grant is recorded. Each item should carry a unique id, and the player's saved data should remember which ids were granted, so a repeat is ignored.
  • A queue per player, read only by the server that player is in, keeps items from being taken by servers that can't grant them. Check that items aren't removed while the player's data isn't loaded, or they're lost.
  • Sorted maps used the same way, read and then removed in two steps, have the same problem. UpdateAsync on a sorted map changes a key in one step.

What an exploiter could do

They make the slow path happen on purpose. Sending many gifts at once, joining when the data store is busy, or leaving at the right moment can push processing past the timeout, and each repeat pays out again. Gifts, mail and trade proceeds can be duplicated, along with anything else the queue carries.

How to fix it

  • Give every queued item a unique id when it's added, such as one from HttpService:GenerateGUID.
  • Before granting, check the id against a list kept in the player's saved data, and record it in the same change that grants the item.
  • Keep the work between ReadAsync and RemoveAsync short, with no yields you don't need, and set an invisibility timeout that covers it.
  • Call RemoveAsync in pcall and log failures. The recorded ids make a missed remove harmless.

Vulnerable example

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

local function collect(player: Player)
	local profile = PlayerData.waitFor(player)
	local queue = MemoryStoreService:GetQueue("Gifts_" .. player.UserId)
	while profile and player.Parent == Players do
		local ok, items, id = pcall(queue.ReadAsync, queue, 10, false, 30)
		if ok and #items > 0 then
			for _, gift in items do
				if typeof(gift) == "number" and gift % 1 == 0 and gift > 0 and gift <= 100000 then
					profile.Coins += gift
				end
			end
			pcall(queue.RemoveAsync, queue, id)
		elseif not ok then
			task.wait(5)
		end
	end
end

Players.PlayerAdded:Connect(collect)

Fixed example

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

local function apply(profile: PlayerData.Profile, gift: unknown)
	if typeof(gift) ~= "table" then
		return
	end
	local fields = gift :: { [string]: unknown }
	local giftId, coins = fields.id, fields.coins
	if typeof(giftId) ~= "string" or typeof(coins) ~= "number" then
		return
	end
	if coins % 1 ~= 0 or coins < 1 or coins > 100000 then
		return
	end
	local key = "Gift:" .. giftId
	if not profile.Claimed[key] then
		profile.Claimed[key] = true
		profile.Coins += coins
	end
end

local function collect(player: Player)
	local profile = PlayerData.waitFor(player)
	local queue = MemoryStoreService:GetQueue("Gifts_" .. player.UserId, 60)
	while profile and player.Parent == Players do
		local ok, items, id = pcall(queue.ReadAsync, queue, 10, false, 30)
		if ok and #items > 0 then
			for _, gift in items do
				apply(profile, gift)
			end
			local removed, err = pcall(queue.RemoveAsync, queue, id)
			if not removed then
				warn(`Gift queue remove failed for {player.UserId}: {err}`)
			end
		elseif not ok then
			task.wait(5)
		end
	end
end

Players.PlayerAdded:Connect(collect)

Common false positives

  • Queues for matchmaking, where reading an entry twice only means a player is considered twice, don't pay out. Check what processing does.
  • Items whose grant is already recorded by id in saved data, like the fixed example, are safe to read twice.
  • A queue item that's removed before a slow step, with the slow step made safe to repeat, is fine.