How to session-lock player data in Roblox

Without a session lock, two servers can hold the same player's data and the later save wins. How session locking with UpdateAsync works.

criticalLuaudit check: missing-session-lock

What it looks like

The server loads a player's profile with GetAsync and saves it with SetAsync, and nothing stops a second server loading the same profile before the first has saved it.

Luau
profiles[player] = store:GetAsync(key)

How to detect it

  • Find how the player's main profile is loaded and saved. Look for a lock: the load marks the record as in use by this server, such as game.JobId and a time written inside UpdateAsync, and refuses or waits when another server's lock is recent. The save on leave releases it.
  • GetAsync followed by SetAsync with no lock is a finding. So is a lock that's read with GetAsync and written separately, since two servers can both read "unlocked" before either writes.
  • A well-known session-locking profile library counts as a lock. Check the game uses its load and release functions rather than reading the store directly.
  • The impact is highest when saved items or currency can move between players, through trading, gifting, tipping or a shared bank. Note which features do.

What an exploiter could do

When a player leaves, their save takes a moment to land. If they join another server in that gap, it loads their old data. Anything they gave away in the first server, they still have in the second, so items and currency can be duplicated. Even without trading, the two servers' saves can overwrite each other and lose progress.

How to fix it

  • Load with UpdateAsync. Inside it, if another server's lock is recent, return nil to leave the record untouched and try again shortly; otherwise write this server's game.JobId and the time, and keep the data.
  • Release the lock in the same UpdateAsync that saves on leave, and only if this server still holds it. Release it too if the player leaves while their data is still loading.
  • Refresh the lock's time on each autosave, and treat a lock older than the expiry as abandoned, so a crashed server doesn't lock a player out for good.
  • If the lock can't be claimed after a few tries, kick the player with a message rather than loading data another server may still change.
  • Using an established session-locking module does all of this and is usually the better choice.

Vulnerable example

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

type Saved = {
	coins: number,
	inventory: { string },
}

local store = DataStoreService:GetDataStore("Profiles")
local profiles: { [Player]: Saved } = {}

Players.PlayerAdded:Connect(function(player)
	local ok, saved = pcall(function()
		return store:GetAsync(tostring(player.UserId))
	end)
	if not ok then
		player:Kick("We couldn't load your data. Please rejoin in a moment.")
		return
	end
	profiles[player] = saved or { coins = 0, inventory = {} }
end)

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

Fixed example

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

type Saved = {
	coins: number,
	inventory: { string },
}

type Lock = {
	jobId: string,
	time: number,
}

type Record = {
	data: Saved,
	lock: Lock?,
}

local store = DataStoreService:GetDataStore("Profiles")
local lockExpiry = 30 * 60
local autosaveEvery = 5 * 60
local profiles: { [Player]: Saved } = {}

local function lockedElsewhere(record: Record?): boolean
	local lock = record and record.lock
	return lock ~= nil and lock.jobId ~= game.JobId and os.time() - lock.time < lockExpiry
end

local function claim(key: string): Saved?
	local claimed: Saved? = nil
	local ok = pcall(function()
		store:UpdateAsync(key, function(old: Record?): Record?
			claimed = nil
			if lockedElsewhere(old) then
				return nil
			end
			local record: Record = old or { data = { coins = 0, inventory = {} } }
			record.lock = { jobId = game.JobId, time = os.time() }
			claimed = record.data
			return record
		end)
	end)
	return if ok then claimed else nil
end

local function save(player: Player, data: Saved)
	pcall(function()
		store:UpdateAsync(tostring(player.UserId), function(old: Record?): Record?
			if lockedElsewhere(old) then
				return nil
			end
			local lock = if profiles[player] == data then { jobId = game.JobId, time = os.time() } else nil
			return { data = data, lock = lock }
		end)
	end)
end

Players.PlayerAdded:Connect(function(player)
	local key = tostring(player.UserId)
	local data = claim(key)
	for attempt = 1, 4 do
		if data or player.Parent ~= Players then
			break
		end
		task.wait(attempt * 3)
		data = claim(key)
	end
	if player.Parent ~= Players then
		if data then
			save(player, data)
		end
		return
	end
	if not data then
		player:Kick("Your data is still saving from another server. Please rejoin in a moment.")
		return
	end
	profiles[player] = data
end)

Players.PlayerRemoving:Connect(function(player)
	local data = profiles[player]
	profiles[player] = nil
	if data then
		save(player, data)
	end
end)

task.spawn(function()
	while true do
		task.wait(autosaveEvery)
		for player, data in profiles do
			task.spawn(save, player, data)
		end
	end
end)

Common false positives

  • Data that one server owns alone and that can't be traded, such as per-player settings, gains little from a lock. Report it only if two servers saving it could lose something that matters.
  • A lock taken inside UpdateAsync and released on save, with an expiry, is the fix. Don't report it because it's hand-written.
  • Places in the same experience share data stores, so a lock that works across places is still the right fix there.