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.
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,IncrementAsyncandRemoveAsync, including ordered data stores, and trace where each key string comes from. UserIdis the only property Roblox describes as unique to an account and never changing. A key that includesNameorDisplayName, on its own or inside a longer string, is a finding.DisplayNameis 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.Nameis 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 exampletostring(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
UserIdkey, and stop reading name keys after that. Only do this forNamekeys: aDisplayNamekey 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
Namekey and moves it to aUserIdkey is the fix in progress. Report it only if the game keeps writing to the name key.