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.
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.
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/, especiallymembershipswith:assignRole,:unassignRoleorPATCH, and for API keys read withHttpService: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
Successbefore saving that a role was given.
Vulnerable example
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
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.