Why Roblox player data should be saved by UserId

Data store keys built from a player's name or display name break when they change it, and display names aren't unique. How to key saves by UserId instead.

criticalLuaudit check: name-based-keys

What it looks like

A data store key is built from player.Name or player.DisplayName instead of player.UserId.

Luau
local saved = store:GetAsync(player.Name)

How to detect it

  • Find every key passed to GetAsync, SetAsync, UpdateAsync, IncrementAsync and RemoveAsync, including ordered data stores, and trace where each key string comes from.
  • UserId is the only property Roblox describes as unique to an account and never changing. A key that includes Name or DisplayName, on its own or inside a longer string, is a finding.
  • DisplayName is the worst case. Display names aren't unique, so two players with the same display name read and write the same key. Players can also change their display name, and the next load finds nothing.
  • Name is unique at any one time, but players can change it, so their next load uses a new key and their data looks lost.
  • Also check keys built from things the player can edit, such as a team name or a pet's nickname.

What an exploiter could do

With DisplayName keys, a player can set their display name to match another player's and load that player's data, or have their own saves overwrite it. With Name keys no exploit is needed: anyone who changes their username starts again from nothing, and their old data sits under a key the game never reads.

Roblox's automated right-to-be-forgotten deletion matches keys or store names that contain the UserId, so data saved under names can't be cleared that way either.

How to fix it

  • Key player data by player.UserId, for example tostring(player.UserId) or "Player_" .. player.UserId.
  • Store names only as values, for display, never as part of the key.
  • To move existing data, read the old name key once when a player joins, save it under the UserId key, and stop reading name keys after that. Only do this for Name keys: a DisplayName key may hold someone else's data, so don't migrate it automatically.

Vulnerable example

Vulnerable
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")

local store = DataStoreService:GetDataStore("Wins")
local wins: { [Player]: number } = {}

Players.PlayerAdded:Connect(function(player)
	local ok, saved = pcall(function()
		return store:GetAsync(player.Name)
	end)
	if not ok then
		player:Kick("We couldn't load your data. Please rejoin in a moment.")
		return
	end
	wins[player] = if typeof(saved) == "number" then saved else 0
end)

Players.PlayerRemoving:Connect(function(player)
	local count = wins[player]
	wins[player] = nil
	if count then
		pcall(function()
			store:SetAsync(player.Name, count)
		end)
	end
end)

Fixed example

Fixed
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")

local store = DataStoreService:GetDataStore("Wins")
local wins: { [Player]: number } = {}

local function keyFor(player: Player): string
	return "Player_" .. player.UserId
end

Players.PlayerAdded:Connect(function(player)
	local ok, saved = pcall(function()
		return store:GetAsync(keyFor(player))
	end)
	if not ok then
		player:Kick("We couldn't load your data. Please rejoin in a moment.")
		return
	end
	wins[player] = if typeof(saved) == "number" then saved else 0
end)

Players.PlayerRemoving:Connect(function(player)
	local count = wins[player]
	wins[player] = nil
	if count then
		pcall(function()
			store:SetAsync(keyFor(player), count)
		end)
	end
end)

Common false positives

  • A name stored inside the saved value, such as the last known username for a leaderboard, is fine. Only the key matters.
  • Keys built from fixed strings the game controls, such as "Global" or a server-chosen guild id, aren't player names.
  • A one-off migration that reads an old Name key and moves it to a UserId key is the fix in progress. Report it only if the game keeps writing to the name key.