Why the server should pick the avatar creation token
The token passed to PromptCreateAvatarAssetAsync sets the price, so a token or tier the client picks buys premium designs at the cheapest price. How to tie each design to its token.
What it looks like
The game lets players buy avatar items they make in the experience, through AvatarCreationService:PromptCreateAvatarAsync, PromptCreateAvatarAssetAsync or PromptCreateMakeupAsync. Each prompt takes an avatar creation token id, and the token sets the price. The server takes that token id from the client, or picks it from a price tier the client names separately from the item being made.
remotes.PublishHat.OnServerEvent:Connect(function(player, design, tokenId) local hat = HatMaker.build(design) AvatarCreationService:PromptCreateAvatarAssetAsync(tokenId, player, hat, Enum.AvatarAssetType.Hat) end)
What an exploiter could do
Roblox's reference says the price of a creation "is dictated by the price attributed to the avatar creation token", and the only checks it lists are that the token belongs to this universe and matches the asset type. An exploiter sends the id of the game's cheapest token of the right type, or names the cheapest tier, and buys the game's premium designs at the lowest price it sells anything for. Every creation made that way loses the developer the difference.
How to fix it
- Keep the token ids in server code, in a table keyed by the design or by the tier the design belongs to.
- Let the client send at most which design it wants. Look up the design in a server table and take the token from that entry.
- Don't send token ids to the client. A shop menu only needs the design names and prices to show.
Vulnerable example
local AvatarCreationService = game:GetService("AvatarCreationService") local ReplicatedStorage = game:GetService("ReplicatedStorage") local ServerScriptService = game:GetService("ServerScriptService") local HatMaker = require(ServerScriptService.HatMaker) local busy: { [Player]: boolean } = {} ReplicatedStorage.Remotes.PublishHat.OnServerEvent:Connect(function(player: Player, design: unknown, tokenId: unknown) if typeof(design) ~= "string" or typeof(tokenId) ~= "string" or busy[player] then return end local hat = HatMaker.build(design) if not hat then return end busy[player] = true local ok, result = pcall(AvatarCreationService.PromptCreateAvatarAssetAsync, AvatarCreationService, tokenId, player, hat, Enum.AvatarAssetType.Hat) hat:Destroy() busy[player] = nil if ok then ReplicatedStorage.Remotes.HatPublished:FireClient(player, result == Enum.PromptCreateAssetResult.Success) end end)
Fixed example
local AvatarCreationService = game:GetService("AvatarCreationService") local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local ServerScriptService = game:GetService("ServerScriptService") local HatMaker = require(ServerScriptService.HatMaker) local tokens = { Basic = "your-basic-hat-token-id", Deluxe = "your-deluxe-hat-token-id", } local designs: { [string]: { tier: "Basic" | "Deluxe" } } = { Beanie = { tier = "Basic" }, Cap = { tier = "Basic" }, Crown = { tier = "Deluxe" }, } local busy: { [Player]: boolean } = {} ReplicatedStorage.Remotes.PublishHat.OnServerEvent:Connect(function(player: Player, design: unknown) if typeof(design) ~= "string" or busy[player] then return end local entry = designs[design] if not entry then return end local hat = HatMaker.build(design) if not hat then return end busy[player] = true local ok, result = pcall(AvatarCreationService.PromptCreateAvatarAssetAsync, AvatarCreationService, tokens[entry.tier], player, hat, Enum.AvatarAssetType.Hat) hat:Destroy() busy[player] = nil if ok and player.Parent == Players then ReplicatedStorage.Remotes.HatPublished:FireClient(player, result == Enum.PromptCreateAssetResult.Success) end 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.