How to grant rewarded video ad prizes safely

A remote that says an ad finished lets anyone skip the ad. Why ad prizes belong in ProcessReceipt, and how to use ShowRewardedVideoAdAsync correctly.

criticalLuaudit check: ad-rewards-outside-receipt

What it looks like

The game offers a prize for watching a rewarded video ad, but hands it out somewhere other than ProcessReceipt: when the client says the ad finished, or straight after ShowRewardedVideoAdAsync returns on the server.

Luau
remotes.AdWatched.OnServerEvent:Connect(function(player)
	profile.Coins += 200
end)

How to detect it

  • Find every use of AdService: ShowRewardedVideoAdAsync, CreateAdRewardFromDevProductId and GetAdAvailabilityNowAsync.
  • Roblox's rewarded video ads guide makes every ad prize a developer product. When the player watches the whole ad, Roblox grants that product through ProcessReceipt, with ProductPurchaseChannel set to Enum.ProductPurchaseChannel.AdReward. Give the prize only in the game's receipt handling. If the game uses BindReceiptHandler for developer products, check which handler gets the ad product.
  • ShowRewardedVideoAdAsync can only be called from the server. A remote the client fires to say an ad finished, or to claim the prize afterwards, comes from a client that never has to show an ad. Any remote that grants an ad prize is a finding.
  • Look for code that grants the prize when ShowRewardedVideoAdAsync returns Enum.ShowAdResult.ShowCompleted. That bypasses Roblox's redelivery, which hands an unfinished receipt back to ProcessReceipt when the player buys again or rejoins, and if ProcessReceipt also grants the product, the player gets it twice.
  • Check the pcall around the call. The guide's own example passes the pcall success flag to the client, but success only means the call didn't error. ShowInterrupted, AdNotReady and the other results also return without an error, so a prize given on success alone is given for ads nobody watched.
  • Check the ad product has a branch in ProcessReceipt. If it doesn't, the receipt goes unanswered and the player gets nothing.

What an exploiter could do

They fire the claim remote whenever they like and collect every ad prize without watching anything, as often as the handler allows. Where the server grants on the call's return as well as the receipt, every watched ad pays twice.

How to fix it

  • Create the reward with CreateAdRewardFromDevProductId and grant it only in ProcessReceipt, recording the PurchaseId like any other product.
  • Use the result of ShowRewardedVideoAdAsync only to tell the client what happened, never to grant anything.
  • The client may ask the server to show an ad, but no remote should grant the prize.

Vulnerable example

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

local remotes = ReplicatedStorage.Remotes
local adPrize = 150

remotes.ClaimAdPrize.OnServerEvent:Connect(function(player: Player)
	local profile = PlayerData.get(player)
	if profile then
		profile.Coins += adPrize
	end
end)

Fixed example

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

local remotes = ReplicatedStorage.Remotes
local adProduct = 1100020
local adPrize = 150
local showing: { [Player]: boolean } = {}

remotes.ClaimAdPrize.OnServerEvent:Connect(function(player: Player)
	if showing[player] then
		return
	end
	showing[player] = true
	local ok, result = pcall(function()
		return AdService:ShowRewardedVideoAdAsync(player, AdService:CreateAdRewardFromDevProductId(adProduct))
	end)
	showing[player] = nil
	if player.Parent then
		remotes.ClaimAdPrize:FireClient(player, ok and result == Enum.ShowAdResult.ShowCompleted)
	end
end)

Players.PlayerRemoving:Connect(function(player)
	showing[player] = nil
end)

MarketplaceService.ProcessReceipt = function(receipt)
	if receipt.ProductId ~= adProduct then
		return Enum.ProductPurchaseDecision.NotProcessedYet
	end
	local player = Players:GetPlayerByUserId(receipt.PlayerId)
	local profile = player and PlayerData.get(player)
	if 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 += adPrize
	end
	return Enum.ProductPurchaseDecision.PurchaseGranted
end

Common false positives

  • A remote that only asks the server to show an ad, and grants nothing itself, is the pattern the guide recommends.
  • Telling the client the result of ShowRewardedVideoAdAsync so it can update its interface is fine, as long as nothing is granted from it.
  • If the game has several ProcessReceipt callbacks, report that as ProcessReceipt set in more than one place, not here.