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.

criticalLuaudit check: client-chosen-keys

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 UserId added 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.