How to stop client strings breaking your data store saves

Data stores only accept valid UTF-8, so one bad string can stop a whole profile saving. How to check with utf8.len and rebuild client tables on the server.

criticalLuaudit check: unsaveable-client-data

What it looks like

A remote puts a string or table from the client into the player's saved data. The server checks its type and length, but not that a data store can save it. One bad value makes the whole save fail, and everything else in that profile stops saving with it.

Luau
remotes.SetPetName.OnServerEvent:Connect(function(player, name)
	if typeof(name) == "string" and #name <= 20 then
		profile.PetName = name
	end
end)

How to detect it

  • Follow every value from a remote argument into saved data, directly or through a module, including keys of tables.
  • Roblox's security guide says data stores only accept valid UTF-8. A string checked only with typeof and # can hold invalid bytes. Look for utf8.len(text) returning a number before the string is stored; it returns nil for invalid UTF-8.
  • Tables from the client must be rebuilt on the server, not stored as sent. Keys can be NaN or nil and values can be Instances. Roblox's security guide says Instance values fail to save and table indices can't be NaN or nil.
  • Roblox's data store guide says not to store inf, -inf or nan, because they don't conform to JSON. Report non-finite numbers from the client as Numbers from the client without range checks.
  • Check what happens when a save fails. If the profile keeps playing and trading while it can't save, a failed save can be used to duplicate items.
  • Size is Saved data with no size limit. This check is about values a data store refuses whatever their size.

What an exploiter could do

They send one string with invalid bytes, or a table with a NaN key, and their profile can no longer save. Roblox's security guide gives the trade case: they trade items away, the other player's save succeeds, their own save fails, and when they rejoin they still have the items. Repeated, that duplicates anything tradeable. Shared saves, such as a guild record or a plot layout others edit, can be broken for everyone in them.

How to fix it

  • Check every string from the client with utf8.len and refuse it if the result is nil, as well as checking its length.
  • Never store a client table as sent. Build a new table on the server from checked keys and values.
  • Keep the whole profile saveable at all times. If a save does fail, stop trades and gifts for that player until a save succeeds.

Vulnerable example

Vulnerable
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PlayerData = require(script.Parent.PlayerData)

local maxLength = 30

ReplicatedStorage.Remotes.NameOutfit.OnServerEvent:Connect(function(player: Player, name: unknown)
	if typeof(name) ~= "string" or #name == 0 or #name > maxLength then
		return
	end
	local profile = PlayerData.get(player)
	if profile then
		profile.Outfit.Label = name
	end
end)

Fixed example

Fixed
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PlayerData = require(script.Parent.PlayerData)

local maxLength = 30

ReplicatedStorage.Remotes.NameOutfit.OnServerEvent:Connect(function(player: Player, name: unknown)
	if typeof(name) ~= "string" or #name == 0 or #name > maxLength then
		return
	end
	if utf8.len(name) == nil then
		return
	end
	local profile = PlayerData.get(player)
	if profile then
		profile.Outfit.Label = name
	end
end)

Common false positives

  • Strings the server picks, such as an item id from a server table chosen by index, are always valid.
  • Text that is only shown, not saved, can't break a save. Judge it with Player text shown to others without filtering.
  • Values matched against a fixed list, such as table.find(allowed, value), are already valid.