Why API keys and webhooks don't belong in client scripts
Any ModuleScript in ReplicatedStorage can be read by exploiters, even if only the server requires it. How to find leaked keys and webhooks and move them to the secrets store.
What it looks like
An API key, an Open Cloud key, a password or a webhook address sits somewhere that reaches the client: a ModuleScript in ReplicatedStorage shared by server and client code, a LocalScript, a StringValue, or an attribute on something in Workspace or ReplicatedStorage. Often it's a settings module that holds harmless values too.
return {
MaxPlayers = 12,
ReportWebhook = "https://discord.com/api/webhooks/123/abc",
}How to detect it
- Search every script and value outside
ServerScriptServiceandServerStoragefor strings that look like credentials: webhook addresses (discord.com/api/webhooks,hooks.),Bearertokens,x-api-key,apiKey,token,secret,password, and long random-looking strings. - Check where each one ends up. A ModuleScript counts as client-readable if it lives in
ReplicatedStorage,ReplicatedFirst,StarterPlayer,StarterGui,StarterPackorWorkspace, even if only server scripts require it. - Check attributes and
ValueBaseobjects set in the project file, in Studio or by server code on anything that replicates. HttpServicerequests only work from the server, so a key in client-readable code has no reason to be there. Follow it to the server script that uses it.
What an exploiter could do
Exploiters can read and decompile every ModuleScript and LocalScript that reaches their client, whether or not it ever runs there. With a webhook address they can post anything they like in your Discord channel or delete the webhook. With an API key they can call that service as your game: an Open Cloud key can change data stores, bans or group roles, depending on what it was given.
How to fix it
- Treat any key that was ever client-readable as leaked. Revoke it with the service that issued it and make a new one, or delete and recreate the webhook.
- Put the new key in the experience's secrets store from the Creator Dashboard and read it on the server with
HttpService:GetSecret. Give it a specific domain rather than*. - Make the request from a server script. If the client needs to trigger it, send a remote with only what the player chose and check it on the server.
- Move server-only settings into a ModuleScript under
ServerScriptServiceorServerStorage.
Vulnerable example
local HttpService = game:GetService("HttpService") local ReplicatedStorage = game:GetService("ReplicatedStorage") local Settings = require(ReplicatedStorage.Shared.Settings) ReplicatedStorage.Remotes.ReportPlayer.OnServerEvent:Connect(function(player: Player, targetId: unknown) if typeof(targetId) ~= "number" or targetId ~= math.floor(targetId) then return end pcall(HttpService.PostAsync, HttpService, Settings.ModWebhook, HttpService:JSONEncode({ content = `{player.UserId} reported {targetId}`, })) end)
Fixed example
local HttpService = game:GetService("HttpService") local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local cooldown = 60 local lastReport: { [Player]: number } = {} ReplicatedStorage.Remotes.ReportPlayer.OnServerEvent:Connect(function(player: Player, targetId: unknown) if typeof(targetId) ~= "number" or targetId ~= math.floor(targetId) then return end local now = os.clock() if now - (lastReport[player] or -math.huge) < cooldown then return end lastReport[player] = now local ok, webhook = pcall(HttpService.GetSecret, HttpService, "ModWebhook") if not ok then return end pcall(HttpService.RequestAsync, HttpService, { Url = webhook, Method = "POST", Headers = { ["Content-Type"] = "application/json" }, Body = HttpService:JSONEncode({ content = `{player.UserId} reported {targetId}`, allowed_mentions = { parse = {} }, }), }) end) Players.PlayerRemoving:Connect(function(player) lastReport[player] = nil end)
Common false positives
- Public identifiers aren't secrets: place, universe, group, game pass and developer product ids, asset ids, and public links such as a group or social page.
- A key that only lives in
ServerScriptServiceorServerStoragecan't be read by clients. Report it as Secrets written into server scripts instead. - A password the server compares with what a client sends, such as an admin key, is an access check rather than a service key. Report it as Admin powers not checked on the server.
- Placeholder values that are clearly not real, such as
"YOUR_KEY_HERE", aren't a leak, but say the real key must not go there.