How to stop Discord webhook spam from your Roblox game
Bug reports and feedback sent to a webhook with no cooldown or mention limits can be flooded or ping everyone. How to add a cooldown, a length cap and allowed_mentions.
What it looks like
A bug report, feedback form, appeal or report-a-player feature takes text from the client and posts it to a webhook or another outside service. The server is missing at least one of: a per-player cooldown, a length limit, or, for Discord webhooks, a setting that stops the message pinging anyone.
remote.OnServerEvent:Connect(function(player, text) HttpService:PostAsync(webhook, HttpService:JSONEncode({ content = text })) end)
How to detect it
- Find remote handlers that pass a client string into the body of a
PostAsyncorRequestAsynccall, directly or through a helper module. - Look for a cooldown kept on the server per player. A cooldown in the LocalScript or the button doesn't count.
- Look for a length check or
string.subon the text before it's sent. - For Discord webhooks, look for
allowed_mentions = { parse = {} }in the payload, or text cleaned of@mentions, when the text goes intocontentor an embed that pings. If the address comes from the secrets store and you can't see it, treat a payload withcontentas a Discord webhook. - Report one finding per handler and list everything it's missing.
What an exploiter could do
They can flood the channel with messages, and ping everyone in your Discord server if mentions aren't turned off. Each request uses the server's HTTP budget of 500 external requests a minute, shared by every script, so a flood can make the game's other requests fail. The receiving service may also start rejecting requests, so real reports get lost.
How to fix it
- Keep a per-player cooldown on the server, and drop or merge reports that arrive too quickly.
- Check the text is a string and cut it to a fixed length with
string.sub, or reject it if it's too long. - For Discord, send
allowed_mentions = { parse = {} }with every message so player text can't ping anyone. - Keep the webhook address in the secrets store and make the request only on the server.
Vulnerable example
local HttpService = game:GetService("HttpService") local ReplicatedStorage = game:GetService("ReplicatedStorage") ReplicatedStorage.Remotes.SendFeedback.OnServerEvent:Connect(function(player: Player, text: unknown) if typeof(text) ~= "string" or text == "" then return end local found, webhook = pcall(HttpService.GetSecret, HttpService, "FeedbackWebhook") if not found then return end pcall(HttpService.PostAsync, HttpService, webhook, HttpService:JSONEncode({ content = `{player.Name}: {text}`, })) end)
Fixed example
local HttpService = game:GetService("HttpService") local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local cooldown = 120 local maxLength = 500 local lastSent: { [Player]: number } = {} ReplicatedStorage.Remotes.SendFeedback.OnServerEvent:Connect(function(player: Player, text: unknown) if typeof(text) ~= "string" or text == "" then return end local now = os.clock() if now - (lastSent[player] or -math.huge) < cooldown then return end lastSent[player] = now local found, webhook = pcall(HttpService.GetSecret, HttpService, "FeedbackWebhook") if not found then return end pcall(HttpService.PostAsync, HttpService, webhook, HttpService:JSONEncode({ content = `{player.Name}: {string.sub(text, 1, maxLength)}`, allowed_mentions = { parse = {} }, })) end) Players.PlayerRemoving:Connect(function(player) lastSent[player] = nil end)
Common false positives
- Messages built only from server values, such as a server's player count or the game's own error messages, aren't player text. Server details sent out belong to Server details sent to outside addresses.
- Requests to the team's own backend that enforces its own limits per player are fine, as long as the server still has a cooldown.
string.subcounts bytes, so a cut can split a multi-byte character. That's untidy, not a security problem.