How to stop players using paid perks they never bought

Hiding a game pass button on the client doesn't protect the perk. How to check ownership on the server in every path that turns a paid perk on.

criticalLuaudit check: unchecked-paid-perks

What it looks like

The game sells a perk with a game pass or developer product, such as auto-collect, a second pet slot, triple hatching or a faster sprint. The shop and the button live on the client, which only shows them to buyers, but the server handler that turns the perk on never checks the player bought it.

Luau
remote.OnServerEvent:Connect(function(player, enabled)
	autoCollect[player] = enabled == true
end)

What an exploiter could do

They turn on the perk without buying it: free auto-collect, extra slots, triple hatching or VIP speed, and everything those earn. The paid perk stops being worth buying, and its advantage over honest players is now free to anyone with an executor.

How to fix it

  • Keep each player's ownership on the server, from UserOwnsGamePassAsync in pcall when they join and from PromptGamePassPurchaseFinished when they buy in game.
  • Check it in every server path that delivers the perk, before doing anything else.
  • Keep the client check for the user interface, but don't rely on it.

Vulnerable example

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

local secondSlotPass = 1300042
local slots: { [Player]: number } = {}

ReplicatedStorage.Remotes.BuySecondSlot.OnServerEvent:Connect(function(player: Player)
	MarketplaceService:PromptGamePassPurchase(player, secondSlotPass)
end)

ReplicatedStorage.Remotes.UseSecondSlot.OnServerEvent:Connect(function(player: Player)
	slots[player] = 2
end)

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

Fixed example

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

local secondSlotPass = 1300042
local owners: { [Player]: boolean } = {}
local slots: { [Player]: number } = {}

Players.PlayerAdded:Connect(function(player)
	local ok, owned = pcall(MarketplaceService.UserOwnsGamePassAsync, MarketplaceService, player.UserId, secondSlotPass)
	if player.Parent == Players then
		owners[player] = owners[player] == true or (ok and owned == true)
	end
end)

MarketplaceService.PromptGamePassPurchaseFinished:Connect(function(player, passId, purchased)
	if purchased and passId == secondSlotPass then
		owners[player] = true
	end
end)

ReplicatedStorage.Remotes.BuySecondSlot.OnServerEvent:Connect(function(player: Player)
	MarketplaceService:PromptGamePassPurchase(player, secondSlotPass)
end)

ReplicatedStorage.Remotes.UseSecondSlot.OnServerEvent:Connect(function(player: Player)
	if owners[player] then
		slots[player] = 2
	end
end)

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

How to spot this in your own scripts, and the cases that only look like it, are part of the Luaudit check your AI runs.