How to use the Roblox secrets store for API keys

Keys typed into server scripts travel with every copy of the place. How to move them into the secrets store and read them with HttpService:GetSecret.

warningLuaudit check: hardcoded-secrets

What it looks like

An API key, token, password or webhook address is typed straight into a server script, a server-only ModuleScript or a value in ServerStorage, or kept in a data store, instead of the experience's secrets store. Clients can't read it, but it travels with the place file.

Luau
local headers = { ["x-api-key"] = "live_9f3c2a7e1b" }

How to detect it

  • Search server scripts, ModuleScripts under ServerScriptService and ServerStorage, and values set in the project file for strings that look like credentials: webhook addresses, Bearer tokens, x-api-key and Authorization headers, keys in query strings such as ?key= or apiKey=, and long random-looking strings.
  • Check data store reads whose result is used as a key or webhook.
  • Compare with HttpService:GetSecret. If the game already uses the secrets store for some keys, any key that doesn't go through it stands out.
  • If the same string also appears somewhere clients can read, report API keys or webhooks that clients can read instead.

What an exploiter could do

Nothing directly, since the script stays on the server. The risk is that the key leaks some other way: a collaborator or someone given edit access copies the place, the place is made copyable, the script ends up in a shared model or package, someone posts it while asking for help, or a later change moves it into ReplicatedStorage. Old versions of the place keep the key even after it's removed from the script. Whoever has it can use the service as your game until the key is changed.

How to fix it

  • Add the key to the experience's secrets store from the Creator Dashboard, with a specific domain, and read it with HttpService:GetSecret. Add local secrets in Studio's Experience Settings to test in Studio.
  • Build URLs with Secret:AddPrefix and Secret:AddSuffix, or put the secret in a header. A secret can't go in the request body, and printing one shows only its name.
  • Revoke the old key with the service that issued it and make a new one, because older versions of the place still contain it.

Vulnerable example

Vulnerable
local HttpService = game:GetService("HttpService")

local apiKey = "sk_live_51b8f0e2c94d7a63"

local function fetchLeaderboard(): string?
	local ok, body = pcall(HttpService.GetAsync, HttpService, `https://scores.example.com/top?key={apiKey}`)
	return if ok then body else nil
end

return fetchLeaderboard

Fixed example

Fixed
local HttpService = game:GetService("HttpService")

local function fetchLeaderboard(): string?
	local found, apiKey = pcall(HttpService.GetSecret, HttpService, "ScoresKey")
	if not found then
		return nil
	end
	local url = apiKey:AddPrefix("https://scores.example.com/top?key=")
	local ok, body = pcall(HttpService.GetAsync, HttpService, url)
	return if ok then body else nil
end

return fetchLeaderboard

Common false positives

  • Public identifiers aren't secrets: place, universe, group and product ids, and addresses of public pages or feeds that need no key.
  • Keys used only by Studio tooling or plugins, never by live servers, are outside the game's code.
  • A secret fetched with GetSecret and then passed around is fine. Look for where it's created, not where it's used.