Why the client should never choose a data store key
Building a data store key from a user id or slot the client sent lets one player read or overwrite another's data. How to build keys on the server.
What it looks like
The server reads or writes a data store key built from something the client sent: a user id, a save name, a slot or a key the server handed out earlier and gets back from the client.
Luau
remote.OnServerEvent:Connect(function(player, userId, build) store:SetAsync("Build_" .. userId, build) end)
How to detect it
- Find every data store call (
GetAsync,SetAsync,UpdateAsync,IncrementAsync,RemoveAsync) reachable from a remote handler, and trace where its key comes from. - A key that includes any client argument is a finding unless the server first checks that argument against something it owns, such as an allowlist of slot names, with the player's own
UserIdadded by the server. - Watch for round trips. A key the server sent to the client earlier, through a remote or an attribute, is client input when it comes back.
- Reading another player's key is a finding if it returns more than the feature should show, or lets them load that data into their own session.
What an exploiter could do
They point the server at someone else's key. Depending on the call, they can overwrite or delete another player's saved data, read data meant to be private, or copy another player's progress into their own. Each new key they make up also adds to the experience's data store usage.
How to fix it
- Build keys on the server from
player.UserId. Never take a user id or a full key from the client. - If the client picks a slot, check it against a fixed list and add it after the server's part of the key:
player.UserId .. ":" .. slot. - For features that show other players' data, read it on the server and send back only the fields the feature shows.
Vulnerable example
Vulnerable
local DataStoreService = game:GetService("DataStoreService") local ReplicatedStorage = game:GetService("ReplicatedStorage") local outfits = DataStoreService:GetDataStore("Outfits") ReplicatedStorage.Remotes.SaveOutfit.OnServerEvent:Connect(function(player: Player, saveKey: unknown, shirt: unknown) if typeof(saveKey) ~= "string" or typeof(shirt) ~= "string" or #shirt > 30 then return end pcall(function() outfits:SetAsync(saveKey, { shirt = shirt }) end) end)
Fixed example
Fixed
local DataStoreService = game:GetService("DataStoreService") local ReplicatedStorage = game:GetService("ReplicatedStorage") local outfits = DataStoreService:GetDataStore("Outfits") local slots = { "A", "B", "C" } ReplicatedStorage.Remotes.SaveOutfit.OnServerEvent:Connect(function(player: Player, slot: unknown, shirt: unknown) if typeof(slot) ~= "string" or not table.find(slots, slot) then return end if typeof(shirt) ~= "string" or #shirt > 30 then return end pcall(function() outfits:SetAsync(player.UserId .. ":" .. slot, { shirt = shirt }) end) end)
Common false positives
- Keys built only from
player.UserId, or from it plus an allowlisted value, are server-owned. - Read-only lookups of public data, like a global leaderboard read with
OrderedDataStore, don't need a per-player key. - Keys built from ids the server already holds, like a group id from its own config, aren't client input.