luaguides

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. When sandboxing Lua, the responsibility for isolation falls entirely on you — 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 end loop 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. The real danger in sandbox design is not that the code will error out — errors are caught by pcall — but that the code will find a way to reach the real globals through some side channel.

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. The evasion works because yielding from inside the coroutine pauses the debug hook in that thread — when the coroutine resumes, the hook’s instruction counter doesn’t automatically restart from where it left off. One approach is to override coroutine.create to inject a fresh hook on every new thread before the user code runs:

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. If the sandboxed code never sees the coroutine table, it cannot spawn new threads or attempt the yield evasion described above. For most plugin and modding use cases, single-threaded execution is sufficient and eliminates this entire class of escapes.

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. The sandboxed code could enumerate directory contents, read configuration files, or overwrite data that the host application depends on. Even io.tmpfile() can be dangerous when the sandbox runs with the same filesystem permissions as the host. 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. These functions bypass metatable protections entirely because they operate at the C API level rather than going through Lua’s normal table access machinery. Even if you’ve set __metatable = false on the environment table, debug.getmetatable with the right upvalues can still retrieve it. Remove it entirely:

env.debug = nil

If you need some debugging output for the host, pass a controlled wrapper instead of the full library. For instance, you might expose a single sandbox_trace function that writes to a ring buffer the host can inspect, rather than giving the sandboxed code direct access to the debug API.

load, loadfile, dofile, and require load additional code. load with no environment argument uses _G. require can load any installed module, including ones that expose C-level functionality. Remove these unless you explicitly want dynamic loading from a curated set. If your sandbox must support require, create a wrapper that validates the module name against an allowlist and passes a restricted environment to any loaded code — never let require run against the full module search path.

OpenResty: NGINX-level isolation

If you’re embedding Lua through OpenResty (ngx_lua), the sandboxing approach adds a layer: NGINX worker process isolation. The key advantage is that NGINX’s process model already isolates workers from each other, so a crash in one worker doesn’t take down the entire server. But the sandbox primitives — load with restricted environments, metatable guards, and debug.sethook — still apply within each worker.

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 that mirrors the same sandboxing approach used in pure Lua. In OpenResty, you have the additional option of blocking packages at the module loader level — setting package.loaded entries to nil before any user code runs prevents even the most determined sandbox escape through require. The content_by_lua_block below shows how to combine these two layers of protection:

-- 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/__newindex access controls used in sandboxing.
  • OpenResty Getting Started — Running Lua inside NGINX with proper isolation.