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.

warningLuaudit check: client-chosen-creation-tokens

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.

Luau
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

Vulnerable
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

Fixed
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.