How to check a player owns the item or zone they pick
Checking that an id exists isn't the same as checking the player has it. How to check pets, skins, classes and zones against the player's own data.
What it looks like
The client picks something by id: a pet to equip, a zone to travel to, a class, a skin, a weapon. The server checks that the id exists in the game, but not that this player owns it or has unlocked it.
Luau
if typeof(petId) == "string" and pets[petId] then profile.Loadout.Pet = petId end
How to detect it
- For each id or name the client sends, ask whether every player is meant to have every valid value. If some values are bought, earned, unlocked by level or tied to a game pass, the server has to check that this player has that one.
- An allowlist of everything in the game, such as
Catalogue[itemId],table.find(allZones, name)orfolder:FindFirstChild(name), proves the id is real, not that it's theirs. - Look for the matching check against the player's own server-side data: their inventory, their unlocked list, their level, or a game pass check made on the server.
- Locked buttons, hidden options and greyed-out menus on the client don't count. The client can fire the remote with any id.
What an exploiter could do
They pick options they haven't paid for or earned: equip pets and skins they don't own, travel to late-game areas from the start, or play as a class meant for higher levels. Anything the game sells or locks behind progress becomes free for them.
How to fix it
- After checking the id exists, check it against the player's own data on the server, such as
table.find(profile.Inventory, petId)orprofile.Level >= zone.level. - Keep what unlocks each option next to the option on the server, so a new option can't be added without its requirement.
- For game pass content, check ownership on the server with
MarketplaceService:UserOwnsGamePassAsync, wrapped inpcall, or read a flag the server set when the player joined or bought the pass.
Vulnerable example
Vulnerable
local ReplicatedStorage = game:GetService("ReplicatedStorage") local PlayerData = require(script.Parent.PlayerData) local pets: { [string]: { speedBonus: number } } = { Puppy = { speedBonus = 1 }, Kitten = { speedBonus = 1 }, Dragon = { speedBonus = 6 }, } ReplicatedStorage.Remotes.EquipPet.OnServerEvent:Connect(function(player: Player, petId: unknown) if typeof(petId) ~= "string" or not pets[petId] then return end local profile = PlayerData.get(player) if profile then profile.Loadout.Pet = petId end end)
Fixed example
Fixed
local ReplicatedStorage = game:GetService("ReplicatedStorage") local PlayerData = require(script.Parent.PlayerData) local pets: { [string]: { speedBonus: number } } = { Puppy = { speedBonus = 1 }, Kitten = { speedBonus = 1 }, Dragon = { speedBonus = 6 }, } ReplicatedStorage.Remotes.EquipPet.OnServerEvent:Connect(function(player: Player, petId: unknown) if typeof(petId) ~= "string" or not pets[petId] then return end local profile = PlayerData.get(player) if profile and table.find(profile.Inventory, petId) then profile.Loadout.Pet = petId end end)
Common false positives
- Options every player has by design, such as free emotes, colour choices or settings, only need an allowlist.
- An ownership or level check inside a helper module counts. Read the helper before reporting.
- A shared module in
ReplicatedStoragethat lists requirements is fine for showing locks in the UI. The finding is the server not checking them, not the client being able to read them.