Why the server shouldn't check a game pass id the client sends

UserOwnsGamePassAsync answers for any pass on Roblox. How to keep your own table of pass ids and let the client pick only which perk to check.

criticalLuaudit check: client-chosen-pass-ids

What it looks like

The server checks game pass ownership properly, with UserOwnsGamePassAsync on the server, but the pass id it checks comes from the client, or the perk it grants is worked out from something about the pass the client picked rather than from the game's own list of passes.

Luau
remotes.ClaimPass.OnServerEvent:Connect(function(player, passId, perk)
	if MarketplaceService:UserOwnsGamePassAsync(player.UserId, passId) then
		grantPerk(player, perk)
	end
end)

How to detect it

  • Find every UserOwnsGamePassAsync call and trace where its pass id comes from. It should be a constant or a value from a table kept in server code.
  • A pass id taken from a remote argument, a RemoteFunction answer, or an attribute or value the client can change is a finding, even when the check itself runs on the server.
  • Check how the perk is chosen. It must come from the game's own mapping of pass id to perk. A perk named by the client, or found from the pass's name, description or price through GetProductInfoAsync, is a finding.
  • A server table that maps the client's choice to a pass id, such as passes[perkName], is the safe shape: the client only picks which pass to check.
  • Do the same for PromptGamePassPurchaseFinished handlers. Granting a perk for whatever gamePassId the event reports, without matching it against the game's own passes, has the same problem.

What an exploiter could do

Roblox's reference describes UserOwnsGamePassAsync as checking whatever pass id it's given, with nothing limiting it to this game's passes. An exploiter makes a cheap pass in their own experience, or uses any pass they already own, and sends its id. If the server trusts the id, or reads the perk from the pass's name, they get every perk the game sells, such as VIP, double coins or exclusive items, without buying any of them.

How to fix it

  • Keep a server table from each of the game's pass ids to the perk it unlocks.
  • Let the client send at most a perk name, look up the pass id in that table, and check ownership of that id.
  • Never decide what to grant from anything about a pass the client chose, including its name or price.

Vulnerable example

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

local perks: { [string]: (Player) -> () } = {
	DoubleCoins = function(player)
		player:SetAttribute("CoinMultiplier", 2)
	end,
	Vip = function(player)
		player:SetAttribute("Vip", true)
	end,
}

ReplicatedStorage.Remotes.ClaimPass.OnServerEvent:Connect(function(player: Player, passId: unknown, perk: unknown)
	if typeof(passId) ~= "number" or typeof(perk) ~= "string" or not perks[perk] then
		return
	end
	local ok, owned = pcall(MarketplaceService.UserOwnsGamePassAsync, MarketplaceService, player.UserId, passId)
	if ok and owned then
		perks[perk](player)
	end
end)

Fixed example

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

type Perk = {
	passId: number,
	grant: (Player) -> (),
}

local perks: { [string]: Perk } = {
	DoubleCoins = {
		passId = 9100001,
		grant = function(player)
			player:SetAttribute("CoinMultiplier", 2)
		end,
	},
	Vip = {
		passId = 9100002,
		grant = function(player)
			player:SetAttribute("Vip", true)
		end,
	},
}

ReplicatedStorage.Remotes.ClaimPass.OnServerEvent:Connect(function(player: Player, name: unknown)
	local perk = if typeof(name) == "string" then perks[name] else nil
	if not perk then
		return
	end
	local ok, owned = pcall(MarketplaceService.UserOwnsGamePassAsync, MarketplaceService, player.UserId, perk.passId)
	if ok and owned then
		perk.grant(player)
	end
end)

Common false positives

  • A client that sends a pass id only to open a purchase prompt is fine. Prompting isn't granting, and the purchase still goes through Roblox.
  • Showing which passes a player owns, for a shop menu, from a client-chosen id gives nothing away if nothing is granted.
  • Checking passes from the game's own list against a client-chosen perk name, as above, is the fix, not a finding.