luau-shield is an open-source static security analyzer for Roblox games
written in Luau. It finds the bugs exploiters actually use: values sent by
the client through remotes that reach currency, DataStores, character
properties, loadstring, HTTP requests or other players' objects without
being validated.
It is a defensive tool: run it on your own code, locally or in CI, before an exploiter finds the hole for you.
- Real data-flow analysis, not regular expressions. luau-shield parses
every file into a syntax tree and follows untrusted values through
variables, tables, string building, local functions and
required modules. - Understands validation.
typeofchecks, bounds, NaN checks, allow-list lookups, ownership checks, early returns,assert, your own validation functions and thetlibrary are recognised, so checked values are not reported. - Clear findings. Every finding shows the code, the path of the data from the remote to the dangerous line, why it is exploitable and how to fix it.
- Made for CI. Text, JSON and SARIF output, exit codes, severity thresholds, a GitHub Action that uploads results to the Security tab.
- One small binary for Windows, macOS and Linux, no runtime dependencies. Installable with Rokit.
Similar tools exist: LuauGuard looks for risky patterns with regular expressions and BeeScan looks for backdoors. luau-shield focuses on precision: few false positives, and an explanation for every finding.
- Installation
- Usage
- Example output
- Rules
- How it works
- Configuration
- Suppressing a finding
- Continuous integration
- AI explanations with Ollama (optional)
- Accuracy
- Limitations
- Development
With Rokit (recommended for Roblox projects):
rokit add Topogamer0/luau-shieldThis adds a line to your rokit.toml:
[tools]
luau-shield = "Topogamer0/luau-shield@0.2.0"Aftman and Foreman work the same way (luau-shield = "Topogamer0/luau-shield@0.2.0"
in aftman.toml, or luau-shield = { github = "Topogamer0/luau-shield", version = "0.2.0" }
in foreman.toml).
Prebuilt binaries for Windows x64, macOS (Apple Silicon and Intel) and Linux x64 are attached to every GitHub release. The Linux binary is fully static; the Windows binary does not need the Visual C++ runtime.
From source (Rust 1.85 or newer):
cargo install --git https://github.com/Topogamer0/luau-shield --lockedluau-shield check # analyse the current directory
luau-shield check src # analyse a directory
luau-shield check --format json -o report.json
luau-shield check --format sarif -o luau-shield.sarif
luau-shield check --min-severity high # only high and critical findings
luau-shield check --disable luau-shield007 # turn a rule off
luau-shield rules # list the rules
luau-shield init # write a starter luau-shield.toml| Option | Meaning |
|---|---|
-f, --format text|json|sarif |
Output format (default text). |
-o, --output FILE |
Write the report to a file instead of stdout. |
-c, --config FILE |
Configuration file (default: luau-shield.toml in the checked directory or a parent). --no-config ignores it. |
--project FILE |
Rojo project used to map files to the DataModel (default: default.project.json). |
-s, --min-severity LEVEL |
Only report findings at or above info, low, medium, high or critical. Default: low (info findings, such as admin-only code, are hidden). |
--fail-on LEVEL |
Exit with code 1 when a reported finding is at or above this severity (never to always succeed). Default: low. |
--disable RULES, --only RULES |
Comma-separated rule codes (luau-shield003) or names (unverified-client-instance). |
--explain, --ollama-model MODEL |
Ask a local model for a detailed explanation (see below). |
--color auto|always|never |
Colors in text output (NO_COLOR is honoured). |
-q, --quiet |
Only print the summary line. |
--allow-parse-errors |
Do not fail when some files cannot be read or parsed (they are skipped with a warning). |
Exit codes: 0 no finding at or above --fail-on, 1 findings,
2 usage, configuration or I/O error, or files that could not be analysed
(a file the parser does not understand is never skipped silently; pass
--allow-parse-errors or set allow-parse-errors = true to accept it).
luau-shield knows which code runs where: *.server.luau scripts run on the
server, *.client.luau LocalScripts on the client, and ModuleScripts take the
context of their location in the Rojo project (ServerScriptService and
ServerStorage are server-only, ReplicatedStorage, ReplicatedFirst,
StarterPlayer, StarterGui replicate to clients). Without a Rojo project,
directory names such as server, client and shared are used.
high[luau-shield001]: Client-controlled value written to currency, stats or saved data
--> examples/vulnerable-game/src/server/Modules/DataManager.luau:74:3
|
73 | for key, value in changes do
74 | profile[key] = value
| ^^^^^^^^^^^ `key` (client-controlled, from `changes`) chooses which entry of `profile` is written
|
= data flow:
1. examples/vulnerable-game/src/server/PlayerData.server.luau:47:69
`changes` (parameter #2) of the `Remotes.SaveSettings.OnServerEvent` handler is sent by
the client (the type annotation `{ [string]: any }` is not checked at runtime)
2. examples/vulnerable-game/src/server/PlayerData.server.luau:48:2
passed to `DataManager.saveSettings` as `changes`
3. examples/vulnerable-game/src/server/Modules/DataManager.luau:73:6
assigned to `key`
4. examples/vulnerable-game/src/server/Modules/DataManager.luau:74:3
`key` (client-controlled, from `changes`) chooses which entry of `profile` is written
= why: Exploiters can fire any remote with any arguments. If the server adds a client-supplied
amount to a currency, saves a client-supplied table or lets the client pick the
DataStore key, an exploiter can give themselves unlimited currency, corrupt other
players' data or poison saves with NaN/huge values. Luau type annotations are erased at
runtime: they do not validate anything.
= fix: The key is not checked against an allow-list (or a strict pattern): the client chooses
which entry is written, for example `Coins` or another player's save. Let the server
decide amounts (look prices and rewards up in a server table by an allow-listed id).
[...]
= docs: https://github.com/Topogamer0/luau-shield/blob/main/docs/rules/luau-shield001.md
Found 27 problems (5 critical, 15 high, 1 medium, 6 low) in 15 files.
The full output for the example game is in
tests/snapshots/snapshots__text_vulnerable_game.snap.
Try it yourself: luau-shield check examples/vulnerable-game, then
luau-shield check examples/fixed-game (no findings).
| Code | Name | Severity | Finds |
|---|---|---|---|
| luau-shield001 | untrusted-economy-write |
high | client values written to currency, leaderstats, player data, DataStores, MemoryStores |
| luau-shield002 | untrusted-instance-property |
high | client values assigned to Position, CFrame, Health, WalkSpeed, Parent, Text... |
| luau-shield003 | unverified-client-instance |
high | Instances sent by the client used without an ownership or location check |
| luau-shield004 | untrusted-code-execution |
critical | client values reaching loadstring, require, getfenv()[x] |
| luau-shield005 | untrusted-http-request |
high | client values in the URL, headers or body of HttpService requests |
| luau-shield006 | server-invokes-client |
medium | RemoteFunction:InvokeClient on the server (can yield forever) |
| luau-shield007 | remote-without-rate-limit |
low | remote handlers doing expensive work (*Async, Instance.new, :Clone()) with no rate limit |
| luau-shield008 | client-exposed-secret |
critical | webhooks, API keys and tokens in code that replicates to clients |
Each rule page explains what is detected, the exploit, what counts as a
check, and the known limits. Short examples follow (the handlers come from a
*.server.luau script; Remotes is a folder of RemoteEvents).
-- Vulnerable: the client decides how many coins it gets (1e9, -1e9, NaN...)
Remotes.Collect.OnServerEvent:Connect(function(player, amount)
player.leaderstats.Coins.Value += amount
end)-- Fixed: the server decides the amount, the client only names an allowed pickup
local REWARDS = { Coin = 1, Gem = 10 }
Remotes.Collect.OnServerEvent:Connect(function(player, pickupKind)
local reward = REWARDS[pickupKind]
if not reward then return end
player.leaderstats.Coins.Value += reward
end)If a number really has to come from the client, check its type, both bounds
and NaN: if typeof(n) ~= "number" or n ~= n or n < 1 or n > 100 then return end.
if n <= 0 or n > 100 then return end alone lets NaN through, and luau-shield
says so.
-- Vulnerable: teleport anywhere
Remotes.Dash.OnServerEvent:Connect(function(player, position)
player.Character.HumanoidRootPart.CFrame = CFrame.new(position)
end)-- Fixed: the target must be a Vector3 close to the character
local MAX_DASH = 20
Remotes.Dash.OnServerEvent:Connect(function(player, position)
local root = player.Character and player.Character:FindFirstChild("HumanoidRootPart")
if not root or typeof(position) ~= "Vector3" then return end
if (position - root.Position).Magnitude > MAX_DASH then return end
root.CFrame = CFrame.new(position)
end)-- Vulnerable: IsA proves it is a Tool, not that it is this player's Tool
Remotes.Unequip.OnServerEvent:Connect(function(player, tool)
if typeof(tool) ~= "Instance" or not tool:IsA("Tool") then return end
tool:Destroy()
end)-- Fixed: the tool must be in the player's own backpack
Remotes.Unequip.OnServerEvent:Connect(function(player, tool)
if typeof(tool) ~= "Instance" or not tool:IsDescendantOf(player.Backpack) then return end
tool:Destroy()
end)-- Vulnerable: remote code execution on the server
Remotes.Console.OnServerEvent:Connect(function(player, source)
loadstring(source)()
end)-- Fixed: the client picks a command from a server-side table
local Commands = {
heal = function(player) player.Character.Humanoid.Health = 100 end,
}
Remotes.Console.OnServerEvent:Connect(function(player, name)
local command = Commands[name]
if command then command(player) end
end)-- Vulnerable: the client controls the path of a request sent by your server
Remotes.Lookup.OnServerEvent:Connect(function(player, path)
HttpService:GetAsync("https://api.example.com/" .. path)
end)-- Fixed: only a numeric id reaches the URL (and the remote is rate limited, see luau-shield007)
Remotes.Lookup.OnServerEvent:Connect(function(player, userId)
local id = tonumber(userId)
if not id then return end
HttpService:GetAsync("https://api.example.com/users/" .. id)
end)-- Vulnerable: an exploiter never answers and this thread waits forever
Remotes.Trade.OnServerEvent:Connect(function(player, partner)
local accepted = Remotes.ConfirmTrade:InvokeClient(partner)
end)-- Fixed: ask with a RemoteEvent, accept the answer on another RemoteEvent
-- (with a server-side timeout), never wait on the client
Remotes.Trade.OnServerEvent:Connect(function(player, partner)
Remotes.TradeRequest:FireClient(partner, player)
end)-- Vulnerable: a DataStore write on every request
Remotes.Save.OnServerEvent:Connect(function(player)
Store:SetAsync(tostring(player.UserId), player.leaderstats.Coins.Value)
end)-- Fixed: at most one save every 30 seconds per player
local lastSave = {}
Remotes.Save.OnServerEvent:Connect(function(player)
local now = os.clock()
if lastSave[player] and now - lastSave[player] < 30 then return end
lastSave[player] = now
Store:SetAsync(tostring(player.UserId), player.leaderstats.Coins.Value)
end)-- Vulnerable (in a LocalScript or a ModuleScript in ReplicatedStorage):
-- every player can read and abuse this webhook
local WEBHOOK = "https://discord.com/api/webhooks/1234567890/AbCdEf..."-- Fixed (in ServerScriptService): use Roblox secrets, on the server only
local WEBHOOK = HttpService:GetSecret("discord_webhook")- Parsing with full-moon, which
supports all of modern Luau: type annotations, generics, type functions,
compound assignment,
ifexpressions, string interpolation, attributes. - Project model: the Rojo project maps files to the DataModel, so
require(game:GetService("ServerScriptService").Modules.Economy),require(script.Parent.Economy),require("./Economy")and.luaurcaliases resolve to files. Each file gets a run context (server, client, replicated). - Sources: the parameters of
OnServerEvent,OnServerInvokeandUnreliableRemoteEventhandlers (exceptplayer), values returned byInvokeClient,Player.Chattedmessages, KnitService.Clientmethods and your own custom sources. - Abstract interpretation: the server code is executed symbolically. Each value remembers which untrusted data it contains and which checks it passed. Branches are followed separately and merged, loops are iterated (field by field over tables with known keys), calls into local functions and required modules are analysed with the actual arguments, validation functions are summarised, and calls too deep to follow are replaced by a summary of what they return.
- Sinks: every rule decides whether the checks are enough for its sink;
for example a number written to a currency needs a type check, both bounds
and a NaN check, while an Instance needs an ownership or location check.
Code that only runs after a permission check on the sender
(
Admins.isAdmin(player)) is reported asinfo("admin only"), hidden by default.
The exact semantics (what counts as a check, what propagates) are documented in docs/analysis.md.
luau-shield init writes a commented luau-shield.toml:
min-severity = "low"
fail-on = "high"
ignore = ["src/dev/**"]
[rules]
luau-shield007 = "off" # disable a rule
luau-shield006 = "high" # change its severity
# Your own validation functions: values that pass them are trusted.
[[sanitizers]]
function = "Validate.amount"
# Your own dangerous functions.
[[sinks]]
function = "Economy.addCoins"
rule = "luau-shield001"
arguments = [2]
# Functions whose parameters come from the client (other networking libraries).
[[sources]]
function = "Net.handlers.*"
trusted-parameters = 1
# Your own permission checks (isAdmin, hasPermission... are built in): code
# after them is reported as "info" ("admin only").
[guards]
functions = ["Staff.canModerate"]Full reference: docs/configuration.md.
When luau-shield is wrong, or the risk is accepted, add a comment with the rule and a reason, on the line of the finding or on the line above it:
coins.Value += amount -- luau-shield-ignore: luau-shield001 amount is validated by the gateway
-- luau-shield-ignore: luau-shield003 doors are re-checked by DoorService
door:Destroy()-- luau-shield-ignore-file: luau-shield007 reason anywhere in a file suppresses a rule
for the whole file. Suppressed findings are counted in the summary.
GitHub Actions, with results in the Security → Code scanning tab and on pull requests:
name: luau-shield
on: [push, pull_request]
permissions:
contents: read
security-events: write
jobs:
luau-shield:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Topogamer0/luau-shield@v0.2.0
with:
path: src
fail-on: highThe action downloads the release binary, runs the analysis, uploads the SARIF
report and then fails the job if there are findings at or above fail-on.
Other CI systems only need the binary and the exit code. See
docs/ci.md.
--explain sends each finding (with the surrounding code) to a local model
served by Ollama and prints a longer explanation and a
suggested fix under the finding. Nothing leaves your machine, and luau-shield
works exactly the same without it.
ollama pull qwen2.5-coder:7b
luau-shield check --explain # uses OLLAMA_HOST or http://localhost:11434
luau-shield check --explain --ollama-model llama3.1 --explain-limit 5The model is set with --ollama-model or [explain] model in the
configuration. Model output can be wrong: the static finding is the source of
truth. Only plain HTTP Ollama endpoints are supported.
The test corpus in tests/fixtures contains, for every rule,
vulnerable cases and closely related safe cases that are validated correctly.
It includes the patterns of real-game reports (v0.2.0: copies by key, admin
commands, sanitizers that build new tables, state machines) and every case
found by the adversarial reviews of each release.
cargo test --test corpus runs luau-shield on all of them and compares the
findings with the expected ones line by line.
| Rule | Vulnerable entries | Safe entries | Expected findings | Found | False positives | False negatives |
|---|---|---|---|---|---|---|
| luau-shield001 | 79 | 50 | 130 | 130 | 0 | 0 |
| luau-shield002 | 22 | 21 | 26 | 26 | 0 | 0 |
| luau-shield003 | 19 | 20 | 23 | 23 | 0 | 0 |
| luau-shield004 | 9 | 10 | 9 | 9 | 0 | 0 |
| luau-shield005 | 14 | 16 | 15 | 15 | 0 | 0 |
| luau-shield006 | 12 | 9 | 14 | 14 | 0 | 0 |
| luau-shield007 | 58 | 25 | 58 | 58 | 0 | 0 |
| luau-shield008 | 14 | 10 | 17 | 17 | 0 | 0 |
| Total | 227 | 161 | 292 | 292 | 0 | 0 |
The example game in examples/vulnerable-game
gives the 27 findings listed in its EXPECTED.md, and its fixed version gives
none.
tests/fixtures/limitations holds 11 cases that luau-shield is known to get
wrong today, giving 7 false-positive findings and 5 missed findings. They are
counted separately so they never hide a regression:
| Case | Mistake | Why |
|---|---|---|
a check on a copy of the variable (local n = amount, n checked) |
false positive | the check refines n, not amount |
asserts inside a function run with pcall |
false positive | the pcall result is not linked to the asserts |
| checks made in a first loop over a table, writes in a second loop | false positive | facts about loop elements stay in their loop |
| a validator closure that checks upvalues instead of parameters (2 writes) | false positive | validator facts are learned for parameters only |
a recursive deepCopy of a nested, checked patch |
false positive | the recursive call is cut off and its result loses the checks |
| a state written first and its previous value tested after (luau-shield007) | false positive | only test-then-change state machines are recognised |
data relayed through a BindableEvent |
false negative | signals are not followed |
data stored in a ValueBase/attribute and read back |
false negative | Instance state is not tracked |
| a write through an alias of a table | false negative | tables are tracked by value |
a module chosen at runtime (require(Shops[mode])) |
false negative | computed requires are not resolved |
| an inventory key checked only with an anchored pattern | false negative | the pattern proves the format, not that the item exists |
Run cargo test --test corpus -- --nocapture to print these numbers (set
LUAU_SHIELD_CORPUS_REPORT=<file> to also write the table to a file).
Static analysis approximates what the program does: it can be wrong in both directions.
- Dynamic code cannot be followed:
getfenv/setfenv,loadstring, metatable tricks, functions chosen at runtime by name, and modules required through computed paths (require(Shops[mode])). - Data that leaves Luau variables is lost: values relayed through
BindableEvents, signals, attributes,
ValueBaseobjects or the DataStore and read back later are not followed. - Tables are tracked by value: a write through one alias of a table is not seen through another alias. A check on a copy of a variable does not protect the original.
- Heuristics on names: currencies and stats are recognised by names
(
leaderstats,Coins,Cash,Gems, ...), DataStores byGetDataStore/*Store, rate limits by time functions, locks and names such ascooldownorRateLimiter. Functions that cannot be resolved but are named like validators (isValidItem) are trusted when used as guards (this can be turned off). - Checks are not proven correct: a bound that is too large still counts as a bound.
- Where checks apply: a check protects the variable it tests, from that point on. Checks on a copy of the variable, on elements in an earlier loop, or inside a validator that reads upvalues instead of its parameters do not protect the original.
- Depth: loops are analysed a bounded number of times and calls are
followed up to
max-call-depth(default 6); deeper calls are replaced by a summary of what the function returns, and sinks inside them are not reported. - Permission guards are trusted by shape: a function named like
isAdminon a permission module, a high group rank or a server list named like an admin list makes the code after it admin only (info). A guard that is wrong (anisAdminthat returns true for everybody) is not detected; turn the built-in guards off with[guards] defaults = false.
Every known mistake has a test in
tests/fixtures/limitations. When luau-shield is
wrong on your code, suppress the finding with a reason, add a
custom sanitizer, or open an issue with a minimal
example.
cargo test # unit, engine, corpus and snapshot tests
cargo test --test corpus -- --nocapture # corpus table with false positives/negatives
cargo insta review # review snapshot changes
cargo clippy --all-targets -- -D warnings
cargo fmt --allTo add a test case, drop a file (or a small Rojo project) in
tests/fixtures/<RULE>/vulnerable or safe and mark expected findings with
-- expect: luau-shield001 at the end of the line (see
tests/fixtures/README.md).