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.
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
UserOwnsGamePassAsynccall. - 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
UserOwnsGamePassAsynccall is insidepcall. 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.PromptGamePassPurchaseFinishedthat grants the perk whenwasPurchasedis 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 inpcall, and retry on failure. - Keep the result in a server table, and update it in a server
PromptGamePassPurchaseFinishedhandler 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
UserOwnsGamePassAsyncevery time.