How to stop a failed data store load wiping player data

Treating a failed load as a new player hands out default data, which is then saved over the real data. How to keep failed loads apart from new players.

criticalLuaudit check: load-failure-overwrites

What it looks like

The server loads a player's data, and when the call fails it carries on as if the player were new: it hands out default data and later saves it over their real data.

Luau
local ok, data = pcall(store.GetAsync, store, key)
profiles[player] = if ok and data then data else defaultProfile()

How to detect it

  • Find every GetAsync, UpdateAsync or library call that loads saved data when a player joins.
  • Check what happens when it fails. Data store calls can error during outages and throttling, which is why they must be wrapped in pcall. An unwrapped call that errors stops the join script, which is a finding on its own.
  • A failed call and "no data yet" are different results. ok == false means the load failed. ok == true with nil means the player is new. Code that treats both the same gives a player who failed to load a fresh profile.
  • Then follow the save path. If a profile that never loaded can still be saved, on leave, on an autosave or at shutdown, the defaults overwrite the real data.

What an exploiter could do

No exploit is needed. During a data store outage or heavy throttling, every player who joins gets fresh data, and when they leave that fresh data replaces everything they had. Players lose their progress and purchases, and the save can't be undone from the game.

How to fix it

  • Keep "failed" apart from "new". Retry a failed load a few times with a short wait between attempts.
  • If it still fails, don't let that profile save. Mark it as not loaded, and either kick the player with a message asking them to rejoin or let them play without saving and tell them so.
  • Save only profiles that loaded successfully.

Vulnerable example

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

local store = DataStoreService:GetDataStore("Coins")
local coins: { [Player]: number } = {}

Players.PlayerAdded:Connect(function(player)
	local ok, saved = pcall(function()
		return store:GetAsync(tostring(player.UserId))
	end)
	coins[player] = if ok and typeof(saved) == "number" then saved else 0
end)

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

Fixed example

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

local store = DataStoreService:GetDataStore("Coins")
local coins: { [Player]: number } = {}

local function load(player: Player): (boolean, unknown)
	for attempt = 1, 3 do
		local ok, saved = pcall(function()
			return store:GetAsync(tostring(player.UserId))
		end)
		if ok then
			return true, saved
		end
		task.wait(attempt * 2)
	end
	return false, nil
end

Players.PlayerAdded:Connect(function(player)
	local ok, saved = load(player)
	if player.Parent ~= Players then
		return
	end
	if not ok then
		player:Kick("We couldn't load your data. Please rejoin in a moment.")
		return
	end
	coins[player] = if typeof(saved) == "number" then saved else 0
end)

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

Common false positives

  • A well-known profile library handles failed loads itself. Check how the game calls it and what it does when a load fails, but don't report the library's internals.
  • Defaults used only while a load is in progress, with saving blocked until it finishes, are fine.
  • A load that fails and leaves nothing saveable, for example because the profile is never created, is safe even without a retry. Mention that a retry or a message would be kinder.