Why numeric keys in saved Roblox data stop matching

Data stores save number keys in tables without a list part as strings, so lookups by UserId or id miss after a rejoin. How to key saved tables with strings.

criticalLuaudit check: numeric-saved-keys

What it looks like

A saved table uses numbers as keys, such as UserIds, product ids, badge ids or level numbers, and the code looks entries up with those numbers. The table isn't a plain list, so its length is 0. Roblox saves the numeric keys as strings, and after the next load a lookup by number finds nothing.

Luau
profile.Thanked[helper.UserId] = true

What an exploiter could do

They rejoin and claim the same rewards again. A once-per-friend bonus, a one-time gift or a reward for each badge pays out again every session, because the record of every earlier claim no longer matches. The same mistake on items or unlocks makes them look missing after a rejoin, and code that then grants them again or resets them can lose or duplicate progress.

How to fix it

  • Use string keys for anything saved: t[tostring(userId)] = true, and look entries up with tostring as well.
  • Or keep a plain list, such as an array of ids, and search it with table.find.
  • For data that's already saved, read both forms once when it loads and rewrite every key as a string.

Vulnerable example

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

local store = DataStoreService:GetDataStore("BadgeBonuses")
local bonusFor: { [number]: number } = {
	[1832457] = 500,
	[1832458] = 2000,
}
local claimed: { [Player]: { [number]: boolean } } = {}

local function grantBonuses(player: Player, coins: NumberValue)
	local mine = claimed[player]
	for badgeId, bonus in bonusFor do
		if not mine[badgeId] then
			mine[badgeId] = true
			coins.Value += bonus
		end
	end
end

Players.PlayerAdded:Connect(function(player)
	local ok, saved = pcall(store.GetAsync, store, tostring(player.UserId))
	if not ok or player.Parent ~= Players then
		return
	end
	claimed[player] = if typeof(saved) == "table" then saved else {}
	grantBonuses(player, player:WaitForChild("Coins") :: NumberValue)
end)

Players.PlayerRemoving:Connect(function(player)
	local mine = claimed[player]
	claimed[player] = nil
	if mine then
		pcall(store.SetAsync, store, tostring(player.UserId), mine)
	end
end)

Fixed example

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

local store = DataStoreService:GetDataStore("BadgeBonuses")
local bonusFor: { [number]: number } = {
	[1832457] = 500,
	[1832458] = 2000,
}
local claimed: { [Player]: { [string]: boolean } } = {}

local function grantBonuses(player: Player, coins: NumberValue)
	local mine = claimed[player]
	for badgeId, bonus in bonusFor do
		local key = tostring(badgeId)
		if not mine[key] then
			mine[key] = true
			coins.Value += bonus
		end
	end
end

Players.PlayerAdded:Connect(function(player)
	local ok, saved = pcall(store.GetAsync, store, tostring(player.UserId))
	if not ok or player.Parent ~= Players then
		return
	end
	claimed[player] = if typeof(saved) == "table" then saved else {}
	grantBonuses(player, player:WaitForChild("Coins") :: NumberValue)
end)

Players.PlayerRemoving:Connect(function(player)
	local mine = claimed[player]
	claimed[player] = nil
	if mine then
		pcall(store.SetAsync, store, tostring(player.UserId), mine)
	end
end)

How to spot this in your own scripts, and the cases that only look like it, are part of the Luaudit check your AI runs.