How to check game pass ownership safely

Trusting the client, skipping pcall or checking only on join all break game pass perks. How to check ownership on the server and update it after a purchase.

warningLuaudit check: gamepass-checks

What it looks like

The game decides whether a player owns a game pass in a way that breaks: the client tells the server, UserOwnsGamePassAsync is called without pcall, or ownership is checked once on join and never updated when the player buys the pass in-game.

Luau
Players.PlayerAdded:Connect(function(player)
	if MarketplaceService:UserOwnsGamePassAsync(player.UserId, vipPassId) then
		grantVip(player)
	end
end)

How to detect it

  • Find every perk tied to a game pass and every UserOwnsGamePassAsync call.
  • If the server learns about ownership from the client, through a remote argument or a remote like "IOwnVip", that's a critical problem. Report it at critical and say why.
  • Check every UserOwnsGamePassAsync call is inside pcall. It's a web call and can fail. Unwrapped, an error stops the rest of that handler, so a VIP player can lose their perks for that session, or other join logic after it never runs.
  • Check what happens when a player buys the pass during the session. Look for a server handler on MarketplaceService.PromptGamePassPurchaseFinished that grants the perk when wasPurchased is true. Without one, buyers get nothing until they rejoin.
  • Perks checked only in a LocalScript, such as a VIP door that opens on the client, give an exploiter the perk for free, but only on their own screen. Check whether anything the server does relies on it.

What an exploiter could do

If the client can claim ownership, they get paid perks without paying. The other cases need no exploit: paying players miss perks they bought, either when the web call fails or until they rejoin after buying.

How to fix it

  • Check ownership on the server with UserOwnsGamePassAsync, wrapped in pcall, and retry on failure.
  • Keep the result in a server table, and update it in a server PromptGamePassPurchaseFinished handler so buyers get the perk straight away.
  • Never accept ownership from the client.

Vulnerable example

Vulnerable
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")

local vipPassId = 123456789
local vip: { [Player]: boolean } = {}

Players.PlayerAdded:Connect(function(player)
	vip[player] = MarketplaceService:UserOwnsGamePassAsync(player.UserId, vipPassId)
	player:SetAttribute("Vip", vip[player])
end)

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

Fixed example

Fixed
local MarketplaceService = game:GetService("MarketplaceService")
local Players = game:GetService("Players")

local vipPassId = 123456789
local vip: { [Player]: boolean } = {}

local function setVip(player: Player, owned: boolean)
	vip[player] = owned
	player:SetAttribute("Vip", owned)
end

Players.PlayerAdded:Connect(function(player)
	for attempt = 1, 3 do
		local ok, owned = pcall(MarketplaceService.UserOwnsGamePassAsync, MarketplaceService, player.UserId, vipPassId)
		if ok then
			if player.Parent == Players then
				setVip(player, owned == true or vip[player] == true)
			end
			return
		end
		task.wait(attempt * 2)
	end
end)

MarketplaceService.PromptGamePassPurchaseFinished:Connect(function(player, passId, wasPurchased)
	if wasPurchased and passId == vipPassId then
		setVip(player, true)
	end
end)

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

Common false positives

  • Cosmetic perks shown only to the buyer, like a VIP chat colour on their own screen, need no server check.
  • A cached ownership table refreshed on purchase is the fix. Don't report it for not calling UserOwnsGamePassAsync every time.