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.

criticalLuaudit check: unchecked-http-responses

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.

Luau
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, GetAsync and PostAsync call whose result decides something: a reward, a permission, a ban, a purchase or saved data.
  • RequestAsync doesn't raise an error for a 4xx or 5xx reply. It returns a table with Success and StatusCode, as Roblox's HTTP guide shows. Check the code reads Success or the status before using Body.
  • GetAsync and PostAsync raise errors on failure, so they need pcall. So does JSONDecode, since an error page isn't JSON.
  • Look at how each field is checked. A field compared with ~= false, ~= nil or not data.denied passes 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 as data.valid == true and 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

Vulnerable
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

Fixed
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.