How to check HttpService responses before trusting them
RequestAsync doesn't raise an error for 4xx and 5xx replies. How to check Success, decode safely and treat anything but a clear yes as a no.
What it looks like
The server asks its own web service a question, such as whether a code is valid, whether a player linked their account or what reward they're owed, and acts on the reply without checking the request succeeded. Error replies, rate-limit replies and pages from a proxy are read as if they were answers, and a missing field counts as a yes.
local response = HttpService:RequestAsync({ Url = url, Method = "GET" }) local data = HttpService:JSONDecode(response.Body) if data.valid ~= false then grant(player, data.reward) end
How to detect it
- Find every
RequestAsync,GetAsyncandPostAsynccall whose result decides something: a reward, a permission, a ban, a purchase or saved data. RequestAsyncdoesn't raise an error for a 4xx or 5xx reply. It returns a table withSuccessandStatusCode, as Roblox's HTTP guide shows. Check the code readsSuccessor the status before usingBody.GetAsyncandPostAsyncraise errors on failure, so they needpcall. So doesJSONDecode, since an error page isn't JSON.- Look at how each field is checked. A field compared with
~= false,~= nilornot data.deniedpasses when the field is missing, so any reply without it is treated as approval. Fields should be checked as the type and value they must have, such asdata.valid == trueand a whole-number reward within a set range. - Check what happens when the call fails. Refusing, retrying later or keeping the request for later is safe. Granting, unbanning or skipping a check is not. Bans that fail open are also Bans that don't last; report them there.
What an exploiter could do
They can't write the reply, but they can choose when to ask. If the service starts rejecting requests, for example when the exploiter fires the remote fast enough to hit its rate limit, every reply is an error without the expected fields. A check that treats a missing field as success then accepts every code, link or claim they send, and pays any reward the error page happens to contain, which may be none, a huge value or a negative one.
How to fix it
- Check
response.Success, or the exact status codes you expect, before reading the body. - Decode inside
pcall, check the result is a table, and check every field's type and range. - Treat anything but a clear yes as a no: compare with
== true, and look the reward up on the server from an id rather than trusting an amount. - Rate-limit the remote per player so it can't be used to push the service into errors.
Vulnerable example
local HttpService = game:GetService("HttpService") local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local PlayerData = require(script.Parent.PlayerData) local apiUrl = "https://api.example.com/v1/links/" local linkReward = 1000 ReplicatedStorage.Remotes.ClaimLinkReward.OnServerEvent:Connect(function(player: Player) local profile = PlayerData.get(player) if not profile or profile.Claimed.LinkReward then return end local ok, response = pcall(HttpService.RequestAsync, HttpService, { Url = apiUrl .. player.UserId, Method = "GET", }) if not ok then return end local decoded, data = pcall(HttpService.JSONDecode, HttpService, response.Body) if decoded and typeof(data) == "table" and data.linked ~= false and player.Parent == Players then profile.Claimed.LinkReward = true profile.Coins += linkReward end 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 apiUrl = "https://api.example.com/v1/links/" local linkReward = 1000 local cooldown = 30 local lastClaim: { [Player]: number } = {} ReplicatedStorage.Remotes.ClaimLinkReward.OnServerEvent:Connect(function(player: Player) local profile = PlayerData.get(player) if not profile or profile.Claimed.LinkReward then return end local now = os.clock() if now - (lastClaim[player] or -math.huge) < cooldown then return end lastClaim[player] = now local ok, response = pcall(HttpService.RequestAsync, HttpService, { Url = apiUrl .. player.UserId, Method = "GET", }) if not ok or not response.Success then return end local decoded, data = pcall(HttpService.JSONDecode, HttpService, response.Body) if decoded and typeof(data) == "table" and data.linked == true and player.Parent == Players and not profile.Claimed.LinkReward then profile.Claimed.LinkReward = true profile.Coins += linkReward end end) Players.PlayerRemoving:Connect(function(player) lastClaim[player] = nil end)
Common false positives
- Responses used only for display, such as news text or a server list, are judged by how they're shown. A failure there means a blank panel, not a free reward.
- Calls whose failure is handled as a refusal, with fields compared against exact values, are the fix.
- Fire-and-forget posts, such as analytics, that decide nothing don't need their reply checked.