Environments, sandboxing and bytecode

Control what a chunk can see with _ENV and load, restrict globals for untrusted code, and treat precompiled bytecode as dangerous input.

_ENV and load

-- in 5.2+ every chunk sees globals through an upvalue called _ENV
print(_ENV == _G)                 --> true in the main chunk

-- assigning _ENV redirects every global access in the chunk
do
  local _ENV = { print = print, x = 10 }
  print(x)                        --> 10
  -- print(y) would be an error: y is not in this environment
end

-- load can set an environment for a whole chunk
local chunk = load("return value * 2", "config", "t", { value = 21 })
print(chunk())                    --> 42

-- load with a custom environment is the basis of every sandbox
local function sandboxed(source, env)
  local fn, err = load(source, "sandbox", "t", env)
  if not fn then return nil, err end
  local ok, result = pcall(fn)
  if not ok then return nil, result end
  return result
end

local safeEnv = {
  math  = math,
  string = string,
  table = table,
  print = print,
}
print(sandboxed("return math.max(1, 5)", safeEnv))    --> 5

-- 5.1 uses setfenv instead
-- local fn = loadstring(source)
-- setfenv(fn, safeEnv)
ItemLua 5.1Lua 5.2+
Chunk environmentsetfenv / getfenvThe _ENV upvalue
Load a stringloadstringload with a string
Load a functionload (function only)load accepts a string or a function
Environment at loadNot supportedFourth argument to load
Global writessetfenv on the functionAssign _ENV inside the chunk

_ENV is an ordinary local variable. Declaring local _ENV = {...} inside a function affects only the code lexically after it, which makes environment changes easy to scope precisely.

Building a sandbox

-- a read-only environment with a metatable guard behind it
local function makeSandbox(extra)
  local base = {
    math   = math,
    string = string,
    table  = table,
    ipairs = ipairs,
    pairs  = pairs,
    tostring = tostring,
    tonumber = tonumber,
    error  = error,
    assert = assert,
  }
  for k, v in pairs(extra or {}) do base[k] = v end

  -- an empty table behind __index means unknown names resolve to nil,
  -- and the sandbox cannot walk up to the real globals
  return setmetatable({}, {
    __index = base,
    __newindex = function(_, k, v)
      error("attempt to write global '" .. tostring(k) .. "'", 2)
    end,
    __metatable = false,             -- hide the metatable from the sandbox
  })
end

local env = makeSandbox({ version = "1.0" })

local fn = assert(load([==[
  local total = 0
  for _, v in ipairs({ 1, 2, 3 }) do total = total + v end
  return total, version
]==], "user-script", "t", env))

print(fn())                          --> 6   1.0

-- what a sandbox must block if the script is untrusted:
--   os.execute, os.remove, os.exit, os.getenv
--   io.* and the debug library in particular
--   load and require, which can pull anything back in
--   setmetatable and getmetatable on host values
--   string.dump is harmless to produce, but the RESULT is dangerous to load

-- a resource limit: count instructions and abort a runaway script
local function runWithLimit(source, env, limit)
  local budget = limit or 1000000
  debug.sethook(function()
    budget = budget - 1
    if budget <= 0 then error("script exceeded its instruction budget") end
  end, "", 1000)                     -- every 1000 instructions

  local ok, result = pcall(load(source, "limited", "t", env))
  debug.sethook()
  return ok, result
end
  • Removing a name from the environment is not enough if a reachable value can produce it again. string exposes nothing dangerous, but debug exposes the registry, and any host function you pass in becomes an escape route.
  • The debug library can read and write locals and upvalues of any function. Never expose it to untrusted code.
  • string.dump produces bytecode that can be edited offline and then loaded, so it is not a form of protection.
  • Set an instruction limit with debug.sethook so a script cannot spin forever. A hook that counts is the only reliable way to bound execution time in pure Lua.
  • Run untrusted code in a separate process or interpreter when the stakes are high. An in-process sandbox in Lua is good but not a security boundary by itself.

Bytecode and what it is not

-- produce a binary chunk
local f = assert(load("return 1 + 1"))
local bytes = string.dump(f)
print(#bytes > 0)                    --> true

-- loading a binary chunk works the same way
local back = assert(load(bytes))
print(back())                        --> 2

-- refuse binary chunks in a sandbox: the mode argument
print(load(bytes, "no-binary", "t"))          --> nil, "attempt to load a binary chunk"
print(load("return 1", "no-binary", "t"))     --> function

-- the three modes are "b" (binary), "t" (text) and "bt" (both)
-- always pass "t" for untrusted source

-- luac produces a file the interpreter can run
--   luac -o app.luac app.lua
--   lua app.luac
-- the file is portable only to the same Lua version and word size
⚠️
A precompiled chunk is not verified data. Malformed bytecode can crash the interpreter or escape a sandbox, because the verifier trusts the structure it reads. Always pass the text-only mode when loading untrusted input, and never accept bytecode over a network or from a file a user can edit.

FAQ

Is a Lua sandbox safe for untrusted code?
Reasonable for scripts from a semi-trusted source such as a configuration author, with the debug library removed, an instruction limit set and a restricted environment. For genuinely hostile input, isolate at the process or container level as well.
How do I run code from a string safely?
Use load(source, name, "t", env), check the two return values, then run it under xpcall with a traceback handler and an instruction budget.

Modules, require and package.path Embedding Lua in a host program

Last refreshed 2026-09-18.