How to grant developer products the right way

Granting products from PromptProductPurchaseFinished or a client remote isn't reliable. Why every grant belongs in ProcessReceipt.

criticalLuaudit check: product-granted-outside-receipt

What it looks like

The game gives the reward for a developer product somewhere other than ProcessReceipt: in a PromptProductPurchaseFinished handler, or in a remote the client fires after the purchase window closes.

Luau
MarketplaceService.PromptProductPurchaseFinished:Connect(function(userId, productId, purchased)
	if purchased then
		giveCoins(userId, 100)
	end
end)

How to detect it

  • Find every connection to PromptProductPurchaseFinished and PromptPurchaseFinished, and every remote whose handler grants currency, items or boosts after a purchase.
  • If any of them grants a developer product's reward, it's a finding. Roblox documents ProcessReceipt as the way to grant developer products. The finished events only say the purchase prompt closed.
  • A remote such as "PurchaseComplete" that the client fires after buying is client input. The client can fire it without buying anything.
  • Game passes are different: they don't go through ProcessReceipt. For those, see Game pass perks checked unsafely.

What an exploiter could do

If a remote grants the reward, they fire it and get the product without paying. If the finished event grants it, rewards can be missed or doubled, because it isn't tied to Roblox's confirmed receipt and retries.

How to fix it

  • Move every developer product grant into ProcessReceipt, following the rules in ProcessReceipt that can lose or repeat a purchase.
  • Keep the finished events and any client remote for the interface only, such as closing a shop window or showing a thank-you message.

Vulnerable example

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

local coinPacks: { [number]: number } = {
	[1111111] = 500,
	[2222222] = 2500,
}

ReplicatedStorage.Remotes.CoinPackBought.OnServerEvent:Connect(function(player: Player, productId: unknown)
	if typeof(productId) ~= "number" then
		return
	end
	local coins = coinPacks[productId]
	local profile = PlayerData.get(player)
	if coins and profile then
		profile.Coins += coins
	end
end)

Fixed example

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

local coinPacks: { [number]: number } = {
	[1111111] = 500,
	[2222222] = 2500,
}

MarketplaceService.ProcessReceipt = function(receipt)
	local coins = coinPacks[receipt.ProductId]
	local player = Players:GetPlayerByUserId(receipt.PlayerId)
	local profile = player and PlayerData.get(player)
	if not coins or not profile then
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end
	local key = "Purchase:" .. receipt.PurchaseId
	if not profile.Claimed[key] then
		profile.Claimed[key] = true
		profile.Coins += coins
	end
	return Enum.ProductPurchaseDecision.PurchaseGranted
end

Common false positives

  • Handlers on the finished events that only update the interface, play a sound or log the result grant nothing.
  • A client remote that asks the server to show the purchase prompt, with MarketplaceService:PromptProductPurchase, is fine. Prompting isn't granting.