Why removed staff keep their powers until they leave

Group rank and role lookups are cached for the session. How to revoke staff powers straight away with a revocation list.

warningLuaudit check: stale-group-ranks

What it looks like

Staff powers come from the player's rank in a Roblox group. The server checks GroupService:GetRolesInGroupAsync, or the Player methods GetRankInGroupAsync, GetRoleInGroupAsync or IsInGroupAsync, and treats the answer as current for the whole session.

Luau
local function isModerator(player)
	return player:GetRankInGroupAsync(staffGroup) >= 200
end

How to detect it

  • Find every group check that grants powers: admin commands, moderation panels, staff-only areas and tools.
  • Roblox's reference for the Player methods says they cache their results: a player who leaves the group during the session is still treated as a member, at their old rank, until they leave the experience. GroupService:GetRolesInGroupAsync, which supersedes GetRankInGroupAsync and GetRoleInGroupAsync, is also cached per user and group and may return an old answer until the cache expires.
  • The reference for GetRolesInGroupAsync also says a role's Rank no longer decides its place in the hierarchy, and to identify roles by their Id. A check like Rank >= 200 may not mean what it used to. Mention it, as advice.
  • Work out how long a server can run with a demoted member in it. A moderator removed for abuse keeps every power until they choose to leave, and servers can run for hours or days.
  • Look for a faster way to take powers away: a ban or block list in a data store, a MessagingService topic that tells servers to revoke someone, or a kick on demotion.
  • Check that the highest powers, such as bans, data edits, currency and shutdowns, don't rest on a group rank alone. A UserId list for those is easier to control.

What an exploiter could do

A moderator who's been demoted or removed, or someone using a stolen moderator account that has been taken out of the group, stays in their current server and keeps using staff commands. They can ban players, change data or give out currency, and nothing stops them until they leave.

How to fix it

  • Keep a revocation list in a data store, check it in every staff command, and publish changes over MessagingService so servers update straight away.
  • Kick a player from all servers when they are removed from staff. GetRolesInGroupAsync may still return its cached answer when they rejoin, so check the revocation list on join as well.
  • Gate the most dangerous commands on a server-side UserId list as well as the group rank.

Vulnerable example

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

local staffGroup = 9100300
local moderatorRoles: { [number]: boolean } = {
	[51001] = true,
	[51002] = true,
}

local function isModerator(player: Player): boolean
	local ok, info = pcall(GroupService.GetRolesInGroupAsync, GroupService, player.UserId, staffGroup)
	if not ok or typeof(info) ~= "table" then
		return false
	end
	for _, role in info.Roles do
		if moderatorRoles[role.Id] then
			return true
		end
	end
	return false
end

ReplicatedStorage.Remotes.StaffKick.OnServerEvent:Connect(function(player: Player, target: unknown)
	if not isModerator(player) then
		return
	end
	if typeof(target) == "Instance" and target:IsA("Player") and target ~= player then
		target:Kick("Removed by a moderator.")
	end
end)

Fixed example

Fixed
local GroupService = game:GetService("GroupService")
local MessagingService = game:GetService("MessagingService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local staffGroup = 9100300
local moderatorRoles: { [number]: boolean } = {
	[51001] = true,
	[51002] = true,
}
local revoked: { [number]: boolean } = {}

pcall(MessagingService.SubscribeAsync, MessagingService, "RevokeStaff", function(message)
	if typeof(message.Data) == "number" then
		revoked[message.Data] = true
	end
end)

local function isModerator(player: Player): boolean
	if revoked[player.UserId] then
		return false
	end
	local ok, info = pcall(GroupService.GetRolesInGroupAsync, GroupService, player.UserId, staffGroup)
	if not ok or typeof(info) ~= "table" then
		return false
	end
	for _, role in info.Roles do
		if moderatorRoles[role.Id] then
			return true
		end
	end
	return false
end

ReplicatedStorage.Remotes.StaffKick.OnServerEvent:Connect(function(player: Player, target: unknown)
	if not isModerator(player) then
		return
	end
	if typeof(target) == "Instance" and target:IsA("Player") and target ~= player then
		target:Kick("Removed by a moderator.")
	end
end)

A real revocation list should also be saved in a data store and loaded when the server starts, so servers that missed the message still apply it.

Common false positives

  • Group checks that only unlock cosmetics, such as a group-member chat tag or a door to a lounge, aren't worth reporting.
  • Games where staff powers come from a server-side UserId list, or that read a revocation list on every command, are fine.
  • If the check also fails open when the lookup errors, report Permission check that lets people in when it fails too.