How to rank players in your group from a game safely

Open Cloud can assign group roles from your game. How to stop players asking for staff roles, and how to limit the API key.

criticalLuaudit check: client-chosen-group-roles

What it looks like

The game ranks players in its Roblox group from inside the game, using an Open Cloud API key and HttpService: a "Veteran" role for reaching a level, a "Supporter" role for buying a pass, or a role picked from a menu. The player's request decides which role, or the server only checks that the role exists, so a player can ask for a staff role and get it.

Luau
remotes.ClaimRole.OnServerEvent:Connect(function(player, roleId)
	if groupRoles[roleId] then
		assignRole(player, roleId)
	end
end)

How to detect it

  • Search for requests to apis.roblox.com/cloud/v2/groups/, especially memberships with :assignRole, :unassignRole or PATCH, and for API keys read with HttpService:GetSecret.
  • Roblox's HTTP guide shows game servers calling Open Cloud to update a member's group role with an API key. Whatever role the request names, the key's owner assigns it.
  • Trace where the role comes from. A role id or name sent by the client, a table of every group role, or a requirement that's missing for some roles lets a player choose a role they haven't earned.
  • Check that the user comes from the server too. A user id sent by the client lets one player rank another.
  • Group roles often unlock staff powers in this game and others: admin commands, moderation panels, Discord roles. See Group ranks checked from a cache that never refreshes and Commands not limited by staff rank for how this game uses them.
  • Roblox's API key guide strongly recommends a separate key on a dedicated account that has only the group access the task needs. Roblox's group membership reference says the requester can only assign or remove roles below their own highest role, so a key from the group owner's account can hand out almost any role.

What an exploiter could do

They fire the remote with the id or name of a moderator or admin role and the game ranks them in the group. From then on every system that trusts the group, in this game and outside it, treats them as staff. With a user id they can also rank friends or demote real staff.

How to fix it

  • Decide the role on the server from the player's own data, and keep a short list of roles the game may assign. Never assign a role the client names.
  • Use an API key from a dedicated account whose highest role sits just above the roles the game hands out, so staff roles stay out of its reach if the code is wrong.
  • Rate-limit role requests per player, and check the request's Success before saving that a role was given.

Vulnerable example

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

local groupId = 4815162
local apiKey = HttpService:GetSecret("GroupRanks")
local groupRoles: { [number]: boolean } = {
	[53000001] = true,
	[53000002] = true,
	[53000003] = true,
}

local function membershipPath(userId: number): string?
	local filter = HttpService:UrlEncode(`user == 'users/{userId}'`)
	local ok, response = pcall(HttpService.RequestAsync, HttpService, {
		Url = `https://apis.roblox.com/cloud/v2/groups/{groupId}/memberships?filter={filter}`,
		Method = "GET",
		Headers = { ["x-api-key"] = apiKey },
	})
	if not ok or not response.Success then
		return nil
	end
	local decoded, body = pcall(HttpService.JSONDecode, HttpService, response.Body)
	local memberships = if decoded and typeof(body) == "table" then body.groupMemberships else nil
	local first = if typeof(memberships) == "table" then memberships[1] else nil
	return if typeof(first) == "table" and typeof(first.path) == "string" then first.path else nil
end

ReplicatedStorage.Remotes.ClaimRole.OnServerEvent:Connect(function(player: Player, roleId: unknown)
	if typeof(roleId) ~= "number" or not groupRoles[roleId] then
		return
	end
	local path = membershipPath(player.UserId)
	if not path then
		return
	end
	pcall(HttpService.RequestAsync, HttpService, {
		Url = `https://apis.roblox.com/cloud/v2/{path}:assignRole`,
		Method = "POST",
		Headers = { ["x-api-key"] = apiKey, ["Content-Type"] = "application/json" },
		Body = HttpService:JSONEncode({ role = `groups/{groupId}/roles/{roleId}` }),
	})
end)

Fixed example

Fixed
local HttpService = game:GetService("HttpService")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local PlayerData = require(script.Parent.PlayerData)

local groupId = 4815162
local apiKey = HttpService:GetSecret("GroupRanks")
local veteranRole = 53000002
local veteranLevel = 50
local cooldown = 60
local lastRequest: { [Player]: number } = {}

local function membershipPath(userId: number): string?
	local filter = HttpService:UrlEncode(`user == 'users/{userId}'`)
	local ok, response = pcall(HttpService.RequestAsync, HttpService, {
		Url = `https://apis.roblox.com/cloud/v2/groups/{groupId}/memberships?filter={filter}`,
		Method = "GET",
		Headers = { ["x-api-key"] = apiKey },
	})
	if not ok or not response.Success then
		return nil
	end
	local decoded, body = pcall(HttpService.JSONDecode, HttpService, response.Body)
	local memberships = if decoded and typeof(body) == "table" then body.groupMemberships else nil
	local first = if typeof(memberships) == "table" then memberships[1] else nil
	return if typeof(first) == "table" and typeof(first.path) == "string" then first.path else nil
end

ReplicatedStorage.Remotes.ClaimRole.OnServerEvent:Connect(function(player: Player)
	local now = os.clock()
	if now - (lastRequest[player] or 0) < cooldown then
		return
	end
	lastRequest[player] = now
	local profile = PlayerData.get(player)
	if not profile or profile.Level < veteranLevel or profile.Claimed.VeteranRole then
		return
	end
	local path = membershipPath(player.UserId)
	if not path then
		return
	end
	local ok, response = pcall(HttpService.RequestAsync, HttpService, {
		Url = `https://apis.roblox.com/cloud/v2/{path}:assignRole`,
		Method = "POST",
		Headers = { ["x-api-key"] = apiKey, ["Content-Type"] = "application/json" },
		Body = HttpService:JSONEncode({ role = `groups/{groupId}/roles/{veteranRole}` }),
	})
	if ok and response.Success then
		profile.Claimed.VeteranRole = true
	end
end)

Players.PlayerRemoving:Connect(function(player)
	lastRequest[player] = nil
end)

The GroupRanks key belongs to a dedicated account whose highest group role is just above Veteran.

Common false positives

  • Role changes decided entirely by the server, from a fixed list of low roles the game is meant to hand out, are fine. Still check the key's account as advice.
  • Reading group information, such as listing roles, doesn't change anything.
  • Staff tools that change roles are Admin powers not checked on the server or Commands not limited by staff rank if their own checks are weak.