Sandboxing Lua for Safe Execution
Lua gives you a completely open global table by default. Any chunk you load can call os.execute(), read files through io.*, introspect the host application, and do almost anything a process can do. There is no built-in sandbox. If you’re evaluating untrusted Lua code, isolation is your job — and getting it wrong has real consequences.
This guide covers the pure-Lua sandboxing primitives available to you: restricted load() environments, metatable-based access controls, and execution limits via debug.sethook. It assumes you have a working Lua 5.3 (or 5.1) installation.
Why Sandboxing Matters
You need a sandbox when Lua evaluates code from a source you don’t fully control:
- Plugin systems in desktop applications let third parties run scripts. Without isolation, a malicious plugin owns the process.
- Game modding exposes Lua to end users. A mod should not be able to read your filesystem or tamper with the game engine.
- Server-side scripting platforms sometimes use Lua as a programmable layer. Untrusted user scripts must be confined.
- Educational REPLs let learners run code in a shared environment. One runaway
while true do endloop shouldn’t freeze the host.
Lua has no SecurityManager equivalent. You build the sandbox from language primitives — which gives you full control, but also full responsibility.
Restricted Environments with load()
The foundation of Lua sandboxing is loading a chunk into a custom environment instead of the real global table.
Lua 5.1: setfenv
local untrusted_code = "print('hello from sandbox')"
local env = {}
setfenv(1, env) -- upcoming chunks see `env` as _G
-- Copy only the functions you want the sandbox to have
env.print = print
env.pairs = pairs
env.ipairs = ipairs
env.table = table
env.string = string
env.math = math
-- Intentionally omitted: io, os, debug, load*, require
local chunk, err = loadstring(untrusted_code)
if chunk then
setfenv(chunk, env)
local ok, result = pcall(chunk)
print(ok, result) -- # true nil
end
setfenv(1, env) changes the global scope for the current chunk. After this call, references to _G (or bare global names) resolve through env instead of the real globals. loadstring (Lua 5.1) or load (5.2+) loads the code without executing it.
Lua 5.2+: load with an Environment Table
Lua 5.2 removed setfenv. Instead, load() accepts an env parameter directly:
local untrusted_code = "print('hello from sandbox')"
local env = {
print = print,
pairs = pairs,
ipairs = ipairs,
table = table,
string = string,
math = math,
-- io, os, debug, load*, require are absent by design
}
local chunk, err = load(untrusted_code, "=chunk", "t", env)
if chunk then
local ok, result = pcall(chunk)
print(ok, result) -- # true nil
end
The fourth argument to load is the environment table. When the chunk accesses a global name, Lua looks it up in env first. If you didn’t add os, then os.execute is nil — no exception, just a clean failure.
The third argument ("t") forces text mode. Never accept precompiled bytecode from an untrusted source — bytecode opcodes can bypass source-level environment restrictions.
Lua 5.4
load(untrusted_code, chunkname, mode, env) works identically in Lua 5.4. The signature hasn’t changed since 5.2.
Whitelisting vs. Blacklisting
There are two mental models for sandboxing:
Blacklisting means removing dangerous functions from the environment. You start with everything and subtract the risks. This is fragile — you have to remember to block io, os, debug, load, loadfile, dofile, require, package, rawget, rawset, setmetatable, getmetatable, and others. Miss one and the sandbox leaks.
Whitelisting means starting from an empty table and adding only what the sandboxed code needs. This is the correct approach:
-- Whitelist: start empty, add safe things
local env = {
print = print,
pairs = pairs,
ipairs = ipairs,
table = table,
string = string,
math = math,
}
-- Attempting to call os.execute raises an error
local chunk = load("os.execute('rm -rf /')", "=chunk", "t", env)
pcall(chunk) -- # false attempt to call a nil value
The key insight: a nil lookup fails silently in expression context, but pcall catches even that as an error. The sandbox degrades gracefully — it doesn’t crash the host, it just stops working.
Protecting the Environment Table with a Metatable
Even a whitelist can be modified by the sandboxed chunk. If the code calls rawset(env, 'os', some_dangerous_table), it has just broken out. Prevent this by wrapping env with a metatable:
local safe_globals = {
print = print,
pairs = pairs,
ipairs = ipairs,
table = table,
string = string,
math = math,
}
local function deny_access()
error("access denied")
end
local env = setmetatable({}, {
__index = safe_globals, -- reads fall through to safe_globals
__newindex = deny_access, -- writes raise an error
__metatable = false, -- getmetatable(env) errors, protecting the metatable
})
local chunk = load("x = 123", "=chunk", "t", env)
pcall(chunk) -- false access denied
With __metatable = false, getmetatable(env) throws an error, which prevents the chunk from retrieving the raw table and bypassing the __newindex guard. Setting __metatable to a string instead of false makes getmetatable return that string, which is slightly less secure but still blocks direct access to the underlying table.
Execution Timeouts with debug.sethook
A runaway script — an infinite loop, runaway recursion, or just a deliberately expensive computation — can hang your host application. debug.sethook lets you install a callback that fires on VM instruction counts:
local function hook(event)
error("execution timeout")
end
debug.sethook(hook, "", 1000) -- fire every 1000 opcodes
local chunk = load(untrusted_code, "=chunk", "t", env)
local ok, err = pcall(chunk)
debug.sethook() -- always remove the hook when done
print(ok) -- # false
print(err) -- # execution timeout
The hook fires on VM opcodes, not source lines. A tight inner loop that does little work per opcode burns through the instruction budget fast. A loop with expensive per-iteration calls burns through it even faster.
Always pair debug.sethook with pcall so a timeout raises a caught error instead of propagating up and crashing the host.
Caveat: The hook callback cannot yield in Lua 5.2+ if it was triggered by a hook — attempting to coroutine.yield() from inside the hook throws an error. Keep the hook simple: just raise an error.
Memory Limits
You can track memory growth during execution and abort if a threshold is exceeded:
local MAX_MEMORY = 1024 * 1024 -- 1 MB
local function hook(event)
collectgarbage() -- run collector for an accurate count
local mem = collectgarbage("count") * 1024
if mem > MAX_MEMORY then
error("memory limit exceeded")
end
end
debug.sethook(hook, "", 5000) -- check every 5000 opcodes
local chunk = load(untrusted_code, "=chunk", "t", env)
local ok, err = pcall(chunk)
debug.sethook()
print(ok, err)
collectgarbage("count") returns the total Lua memory in kilobytes, not just the sandboxed chunk’s allocation. Tune the opcode interval and threshold for your use case. A lower opcode count makes the check more responsive but adds overhead.
Coroutine Escapes
debug.sethook tracks instruction counts per thread. If the sandboxed chunk runs inside a coroutine and that coroutine yields, the hook state suspends too — the callback won’t fire again after the yield. A chunk that deliberately yields at the right moment can evade the instruction counter:
local function evasive()
coroutine.yield() -- yield immediately
while true do end -- infinite loop — hook won't fire
end
local co = coroutine.create(evasive)
local ok, err = pcall(coroutine.resume, co)
print(ok, err) -- # true true (resume succeeds, loop hasn't run yet)
The fix is to prevent the sandboxed chunk from using coroutines at all, or to intercept coroutine.create:
local orig_create = coroutine.create
coroutine.create = function(f)
-- Set a hook on the new coroutine immediately
local co = orig_create(f)
-- For each new coroutine, set your own hook
return co
end
Or simpler: don’t expose coroutine in the environment at all.
The Most Dangerous Functions
Some functions break out of the sandbox more completely than others:
os.execute runs a shell command with the same privileges as the host process. One call and the sandbox is fully escaped. Always remove os from the environment:
env.os = nil
io.* functions can read or write files. Passwords, keys, source code — all accessible. Remove io:
env.io = nil
debug is the other major escape vector. debug.getfenv, debug.setfenv, debug.getmetatable, and debug.setmetatable can pierce sandbox walls. Remove it entirely:
env.debug = nil
If you need some debugging output for the host, pass a controlled wrapper instead of the full library.
load, loadfile, dofile, and require load additional code. load with no environment argument uses _G. require can load any installed module. Remove these unless you explicitly want dynamic loading from a curated set.
OpenResty: NGINX-Level Isolation
If you’re embedding Lua through OpenResty (ngx_lua), the sandboxing approach adds a layer: NGINX worker process isolation.
Block dangerous standard libraries at module load time:
-- In init_by_lua_block
package.loaded["os"] = nil
package.loaded["io"] = nil
package.loaded["debug"] = nil
package.loaded["load"] = nil
Then load user code with a restricted environment:
-- In content_by_lua_block for a /sandbox location
local env = {
print = print,
pairs = pairs,
ipairs = ipairs,
ngx = nil, -- optionally disable the ngx API too
}
local chunk, err = load(user_code, "=chunk", "t", env)
if chunk then
local ok, result = pcall(chunk)
end
The critical point: Lua runs inside the NGINX worker process. A compromised or runaway script can crash the worker. For untrusted content, run the Lua sandbox in a separate subprocess — a dedicated worker or sidecar process — so a crash doesn’t take down the main server. OpenResty’s lua_add_filter (>= 1.19) can inject guard headers, but it doesn’t replace process isolation.
Common Mistakes
Checking for dangerous functions in the wrong place. Setting env.os = nil only removes the os key from env. If the chunk somehow gets a reference to the real _G, it still has access. Make sure your environment table is the only global scope the chunk can reach.
Accepting bytecode. load(code, name, "b", env) loads precompiled bytecode, which can bypass environment restrictions because some opcodes don’t use the environment lookup mechanism. Always use "t" for text mode only.
Forgetting pcall. Without pcall, a sandboxed error propagates up to the host and can crash the process. Always wrap execution in protected call.
Setting __metatable = true. Some tutorials suggest __metatable = true to protect the metatable, but this causes getmetatable(env) to return true rather than error — the chunk can then use the returned value as a table in some circumstances. Use __metatable = false instead.
Conclusion
Sandboxing Lua is a layered problem. The foundation is a custom environment table passed to load() — only explicitly listed functions are accessible. Wrap that table in a metatable to block writes and protect the metatable itself. Use debug.sethook for coarse execution limits, and pair everything with pcall to catch errors cleanly.
The whitelist approach is the only reliable one. Start empty, add safe things, and remove os, io, debug, and the load family entirely. For OpenResty, add process isolation on top of language-level controls.
Lua’s lack of a built-in sandbox is a double-edged sword. You have no security manager fighting you, but you also have no security manager doing the work for you. The primitives are expressive enough to build something solid — it just requires being deliberate about every layer.
See Also
- Functions in Lua — Closures and first-class functions are the building blocks for sandbox environments.
- Lua Metatables — Metatables power the
__index/__newindexaccess controls used in sandboxing. - OpenResty Getting Started — Running Lua inside NGINX with proper isolation.