Why ProcessReceipt should grant by ProductId

Granting the item a player picked before the prompt, instead of the product they paid for, lets them pay for a cheap item and get an expensive one. How to map products to rewards.

criticalLuaudit check: receipt-grants-choice

What it looks like

ProcessReceipt decides what to grant from something other than the receipt: an item the client chose before the prompt, a "pending purchase" table filled by a remote, or an argument the client sent. The developer product that was actually bought doesn't decide what the player gets.

Luau
MarketplaceService.ProcessReceipt = function(receipt)
	grantItem(receipt.PlayerId, pendingItem[receipt.PlayerId])
	return Enum.ProductPurchaseDecision.PurchaseGranted
end

How to detect it

  • Read what ProcessReceipt, and every function it calls to grant, uses to choose the reward. Only receipt.ProductId says what was paid for. Anything the client set, or any value stored per player before the purchase finished, is a finding.
  • Look for one developer product shared by several items, such as a "Buy item" or "Robux purchase" product, with the item chosen elsewhere. Products priced differently but granting from the same pending choice are the same problem.
  • Look for remotes that store a choice and then prompt, such as pending[player] = itemId followed by PromptProductPurchase. A second call can change the choice before the first purchase's receipt arrives, and the receipt grants the newer one.
  • Check what happens when the stored choice is gone. Roblox can deliver the receipt again later, in another server or after the player rejoins, and a choice kept in a script table won't be there.

What an exploiter could do

An exploiter buys the cheapest item, then fires the remote again with the most expensive item before the receipt is processed. The receipt grants the expensive item for the cheap price. With a single shared product, they don't even need the timing: every item costs the same. Honest players can lose out too: a receipt delivered after a rejoin finds no choice and grants nothing, or grants something else.

How to fix it

  • Create one developer product per thing you sell, and map each ProductId to its reward in a server table.
  • In ProcessReceipt, look up the reward from receipt.ProductId alone, and return NotProcessedYet for ids you don't know.
  • If players need to customise a purchase, such as choosing a colour, apply the choice from the player's saved data after the grant, never to decide what they paid for.

Vulnerable example

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

local robuxItemProduct = 1400001
local wanted: { [Player]: string } = {}

ReplicatedStorage.Remotes.BuyItem.OnServerEvent:Connect(function(player: Player, itemId: unknown)
	if typeof(itemId) ~= "string" then
		return
	end
	wanted[player] = itemId
	MarketplaceService:PromptProductPurchase(player, robuxItemProduct)
end)

MarketplaceService.ProcessReceipt = function(receipt)
	local player = Players:GetPlayerByUserId(receipt.PlayerId)
	local profile = player and PlayerData.get(player)
	local itemId = player and wanted[player]
	if receipt.ProductId ~= robuxItemProduct or not profile or not itemId then
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end
	local key = "Purchase:" .. receipt.PurchaseId
	if not profile.Claimed[key] then
		profile.Claimed[key] = true
		table.insert(profile.Inventory, itemId)
	end
	return Enum.ProductPurchaseDecision.PurchaseGranted
end

Fixed example

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

local productFor: { [string]: number } = {
	Sword = 1400001,
	Shield = 1400002,
}
local itemFor: { [number]: string } = {}
for itemId, productId in productFor do
	itemFor[productId] = itemId
end

ReplicatedStorage.Remotes.BuyItem.OnServerEvent:Connect(function(player: Player, itemId: unknown)
	if typeof(itemId) ~= "string" or not productFor[itemId] then
		return
	end
	MarketplaceService:PromptProductPurchase(player, productFor[itemId])
end)

MarketplaceService.ProcessReceipt = function(receipt)
	local itemId = itemFor[receipt.ProductId]
	local player = Players:GetPlayerByUserId(receipt.PlayerId)
	local profile = player and PlayerData.get(player)
	if not itemId or not profile then
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end
	local key = "Purchase:" .. receipt.PurchaseId
	if not profile.Claimed[key] then
		profile.Claimed[key] = true
		table.insert(profile.Inventory, itemId)
	end
	return Enum.ProductPurchaseDecision.PurchaseGranted
end

Common false positives

  • A choice that only changes how the reward looks, such as a cosmetic colour applied after the grant, doesn't change what was bought.
  • Products that grant something the server picks at random are a different question. See Paid random items with no policy check.
  • A pending table used only to show "Thanks for buying" in the UI, with the grant itself based on ProductId, is fine.