How to ban players properly in Roblox

Bans kept in a script table or saved by username don't stop anyone for long. How to use Players:BanAsync or save bans by UserId.

warningLuaudit check: unsaved-bans

What it looks like

The game bans players, but the ban only lives in one server's memory, is saved under the player's name, or is just a kick. The banned player joins another server, rejoins later or changes their name, and plays as normal.

Luau
banned[target.UserId] = true
target:Kick("Banned")

How to detect it

  • Find every ban: admin commands, moderation remotes, anti-cheat that removes players, and any table or data store named like bans or blacklist.
  • Check where the ban is kept. A table in a script is gone when that server closes and was never seen by other servers. A ban that's only a Kick isn't a ban.
  • Check what happens when the lookup fails. A ban check in pcall that lets the player in when the data store errors is part of the finding.
  • Check what the ban is keyed on. It should be the UserId, which never changes. Name and DisplayName can be changed by the player.
  • Check that every join is checked: PlayerAdded must look the ban up, and players already in the server when the script starts must be checked too.
  • If the game uses Players:BanAsync, check it's called in pcall and that Players.BanningEnabled hasn't been turned off.

What an exploiter could do

A banned exploiter is back within a minute by joining another server, or after a name change. Moderators think the problem is dealt with while it carries on, and players stop trusting reports.

How to fix it

  • Use Players:BanAsync, Roblox's built-in ban. It's keyed by UserId, removes banned players who are in the server, stops them joining again, covers every place in the experience unless ApplyToUniverse is set to false, and by default covers suspected alt accounts too.
  • If you keep your own bans, save them in a data store keyed by UserId, check them on every join, and retry a failed lookup. Decide what happens if it still fails: removing the player is safer, but a data store outage then removes everyone.
  • Record who banned whom and why, so bans can be reviewed and undone with Players:UnbanAsync.

Vulnerable example

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

local moderators: { [number]: boolean } = {
	[123456789] = true,
}
local banned: { [number]: boolean } = {}

ReplicatedStorage.Remotes.ModBan.OnServerEvent:Connect(function(player: Player, target: unknown)
	if not moderators[player.UserId] then
		return
	end
	if typeof(target) ~= "Instance" or not target:IsA("Player") or target == player then
		return
	end
	banned[target.UserId] = true
	target:Kick("You are banned.")
end)

Players.PlayerAdded:Connect(function(player)
	if banned[player.UserId] then
		player:Kick("You are banned.")
	end
end)

Fixed example

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

local moderators: { [number]: boolean } = {
	[123456789] = true,
}

ReplicatedStorage.Remotes.ModBan.OnServerEvent:Connect(function(player: Player, target: unknown)
	if not moderators[player.UserId] then
		return
	end
	if typeof(target) ~= "Instance" or not target:IsA("Player") or target == player then
		return
	end
	local ok, err = pcall(Players.BanAsync, Players, {
		UserIds = { target.UserId },
		Duration = -1,
		DisplayReason = "You are banned for exploiting.",
		PrivateReason = `Banned by {player.UserId}`,
		ExcludeAltAccounts = false,
		ApplyToUniverse = true,
	})
	if not ok then
		warn(`Ban failed for {target.UserId}: {err}`)
	end
end)

Common false positives

  • A kick on its own is fine when it isn't meant as a ban, such as removing someone from a round or a server-side speed check that gives up.
  • Short bans kept on purpose for one server, like a vote-kick that only lasts until the server ends, are fine if the game says so.
  • Bans handled by a well-known admin module that saves them by UserId aren't this finding. Check the module's settings rather than its code.