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.

criticalLuaudit check: keys-in-client-code

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.

Luau
return {
	MaxPlayers = 12,
	ReportWebhook = "https://discord.com/api/webhooks/123/abc",
}

How to detect it

  • Search every script and value outside ServerScriptService and ServerStorage for strings that look like credentials: webhook addresses (discord.com/api/webhooks, hooks.), Bearer tokens, 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, StarterPack or Workspace, even if only server scripts require it.
  • Check attributes and ValueBase objects set in the project file, in Studio or by server code on anything that replicates.
  • HttpService requests 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 ServerScriptService or ServerStorage.

Vulnerable example

Vulnerable
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

Fixed
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 ServerScriptService or ServerStorage can'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.