Press enter or click to view image in full size
Applications increasingly execute scripts that are supplied by customers, loaded dynamically, or generated by AI. This post shows how GraalVM sandboxing can restrict what those scripts access, limit the resources they consume, and isolate failures from the host application.
This post is for you if:
- You are developing a script extension system.
- You run customer-provided scripts on your server.
- Your application or AI model generate scripts.
- You embed JavaScript or WebAssembly and do not trust the code or its input.
Using a JavaScript discount rule as a running example, we progress from trusted application code to fully untrusted source. The same approach works whether the host runs on the JVM or as a native executable built with Native Image.
Why script extensions need sandboxing
Modern SaaS applications often let organizations customize business logic instead of forcing every customer through one fixed workflow. No product can anticipate every business model, integration, or policy, so extensibility is a key driver of adoption. An entire industry has grown around customizing business applications.
A custom extension fills in behavior that the application deliberately leaves open. The application defines which data the extension receives, which operations it may call, and which result it must return. The extension supplies the missing decision, transformation, or workflow. Unlike configuration, which selects behavior the product already implements, a script extension can express logic the product developers did not anticipate.
Extensions often take the form of small scripts evaluated at runtime. Scripts are particularly convenient because they can be loaded and changed without rebuilding the application.
When an application starts executing extension code, it crosses a boundary from trusted, well-audited code to more or less unknown territory: The extension code may contain an accidental infinite loop, allocate too much memory, process malicious input, or deliberately attack the runtime.
All the features discussed here are available in both Oracle GraalVM and GraalVM Community Edition.
A discount rule extension
Throughout this post, we use a discount-rule extension that receives an Order and returns a discount rate. It only needs the order total, so Order exposes a single method:
public final class Order { private final double total;
public Order(double total) {
this.total = total;
}
@HostAccess.Export
public double total() {
return total;
}
}
A rule maintained with the application might look like this:
Source rule = Source.create("js", """
(order) => {
if (order.total() >= 100) {
return 0.10;
}
return 0.0;
}
""");try (Context context = Context.newBuilder("js").build()) {
Value discount = context.eval(rule);
double rate = discount.execute(new Order(120)).asDouble();
System.out.println(rate);
}
The flow has four parts. A Source represents the extension code independently of its execution. A Context contains the guest application state and configuration. context.eval(rule) evaluates the source in that context and returns a Value representing the JavaScript function. Calling execute() invokes that function with the exported Order.
The same Source can be evaluated in multiple contexts, with each evaluation producing a Value owned by that context.
Because no sandbox policy is specified, the context uses TRUSTED. Java and JavaScript execute in the same runtime and share the same heap, minimizing interoperability overhead and enabling joint optimization.
For an application-owned rule whose source and inputs are trusted, this may be the right trade-off. But not every extension should be able to reach every host capability or share resources with the application. GraalVM lets us add those boundaries without changing how the rule is represented, evaluated, and executed. We begin by limiting what the extension can access.
Constraining host access
The discount rule needs the order total, but nothing else from the host environment. A regular Context already starts with restrictive defaults: only explicitly exported host members are accessible, while native access, process creation, environment access, and host file and socket access are disabled.
With TRUSTED, these restrictions are defaults that application code can relax while configuring the context. CONSTRAINED enforces a restrictive configuration and rejects incompatible options, including those introduced by shared configuration code or later refactoring.
try (Context context = Context.newBuilder("js")
.sandbox(SandboxPolicy.CONSTRAINED)
.out(OutputStream.nullOutputStream())
.err(OutputStream.nullOutputStream())
.build()) {
Value discount = context.eval(rule);
double rate = discount.execute(new Order(120)).asDouble();
System.out.println(rate);
}Order.total() remains available because it is explicitly marked with @HostAccess.Export. Other host methods and classes are not exposed automatically. Naming "js" also limits the context to JavaScript; constrained contexts require an explicit list of permitted languages.
The policy prevents the final configuration from enabling broad host access, native access, process creation, system exit, host files or sockets, inherited environment variables, or host class loading. If any part of the application requests an incompatible capability, context creation fails rather than silently weakening the policy.
CONSTRAINED requires explicit guest output and error streams. This example discards both. Applications that retain guest output should use bounded, application-owned streams. The final System.out.println is host application code and is unaffected by this redirection.
The exposed surface is deliberately narrow:
Press enter or click to view image in full size
Every exported method remains an application-defined capability. If Order exposed refund() or cancel(), the script could call them. Exported methods must validate guest input and must not expose privileged operations. A sandbox cannot make a dangerous application operation safe.
CONSTRAINED limits the exposure of application code to the extension, but Java and Javascript still share the underlying runtime resources. Restricting CPU and memory usage requires the next level of sandboxing.
Running trusted code on untrusted input
At this point, it is tempting to think that a trusted rule with narrow host access needs no further safeguards. But trusted source does not imply trusted input. An attacker who cannot change the rule may still choose data that drives it into its worst possible behavior. Host-access restrictions do not prevent excessive CPU use, memory allocation, or failures in the guest runtime.
A production rule might parse a coupon, traverse line items, or inspect nested metadata. A crafted coupon can trigger a worst-case parsing path, a huge order can cause large temporary allocations, and an unexpected numeric value can send a loop into a state its author assumed was impossible. Malformed input may even expose a defect in a guest-language library or runtime.
ISOLATED addresses both concerns. In addition to enforcing the capability restrictions of CONSTRAINED, it requires CPU and isolate-heap limits and executes the guest code in a separate runtime instance with its own heap, garbage collector, and JIT compiler.
Isolated execution requires the isolate variant of the Maven language dependency. For JavaScript, use js-isolate with Oracle GraalVM or js-isolate-community with GraalVM Community Edition. See Configuring Polyglot Isolates for dependency details.
try (Context context = Context.newBuilder("js")
.sandbox(SandboxPolicy.ISOLATED)
.out(OutputStream.nullOutputStream())
.err(OutputStream.nullOutputStream())
.option("engine.MaxIsolateMemory", "64MB")
.option("sandbox.MaxCPUTime", "2s")
.build()) {
Value discount = context.eval(rule);
double rate = discount.execute(new Order(120)).asDouble();
System.out.println(rate);
}The source, evaluation, and invocation remain unchanged. The memory and CPU limits shown above are mandatory for ISOLATED; context creation fails if either is omitted. Their values must be chosen by the application. A CPU-bound execution is canceled when it exceeds its configured budget, while guest allocations are accounted against the isolate heap.
The sandbox setup now looks like this:
Press enter or click to view image in full size
By default, the guest runtime runs as an isolate inside the application process. It can also run in an external process when a separate address space and process boundary are required. We will return to that deployment choice later.
The Java API still looks the same, but separate heaps change the lifetime of references that cross between Java and JavaScript. That is the next consequence to consider.
Working with separate heaps
At first, separate heaps appear to make memory ownership simple: host objects live on one heap and guest objects on another. Cross-heap references complicate that picture.
A separate heap gives guest execution its own garbage collector and creates a clear accounting boundary for memory. When Java and JavaScript share a heap, their objects occupy the same memory pool. GraalVM can estimate how much memory a context retains, but that accounting is periodic and its limit may be exceeded before enforcement. It cannot provide a hard guest-only heap ceiling.
An isolate changes that. The engine.MaxIsolateMemory option places a hard cap on the complete guest runtime heap. Guest allocations are accounted against this limit rather than consuming space in the host application heap. This is a limit on the isolate heap, not on every form of native or process memory.
The separate heap does not prevent values from crossing between Java and JavaScript. Objects remain on their owning heap, while references across the boundary are represented by handles behind the scenes. This introduces a problem familiar from distributed garbage collection.
Suppose the discount rule also receives a DiscountLog callback through which it records why a discount was applied:
public final class DiscountLog { private String reason;
@HostAccess.Export
public void report(Value details) {
reason = details.getMember("reason").asString();
}
}
When JavaScript calls log.report({ reason: "large order" }), details is a guest value passed to Java. The implementation copies the required field into a Java String, so the host does not retain a reference into the guest heap.
Without scoped callback values, retaining details in a Java field could create a cross-heap cycle if the guest object referred back to log. The complete cycle might no longer be reachable by the application, yet each collector would still see an incoming reference from the other heap and keep its side alive:
Press enter or click to view image in full size
Distributed garbage-collection problems are easy to dismiss because small tests usually finish before retained objects accumulate, and neither heap contains an obvious leak when examined independently. In production, however, cross-heap cycles can accumulate gradually and surface much later as growing isolate heaps, unexpected resource-limit failures, or reduced throughput. The callback that created the cycle may be far removed from the eventual symptom, making the problem particularly difficult to diagnose.
GraalVM avoids the common accidental form of this cycle by scoping guest values passed to exported Java callbacks. ISOLATED enables this behavior by default, as does UNTRUSTED, the policy for untrusted source discussed in the next section. The host can use such a value while the callback runs, but its reference into the guest heap is released when the callback returns:
Press enter or click to view image in full size
Press enter or click to view image in full size
With scoped callback parameters, details is valid only while report() executes. When the callback returns, the scoped Value is invalidated and its reference into the guest heap is released. Retaining it in a Java field and using it later would result in an IllegalStateException.
If a callback deliberately needs to retain a guest value, it can call details.pin() before returning. Pinning opts that value out of automatic release. It is an explicit lifetime decision and should be used carefully because it can reintroduce a long-lived reference across the two heaps.
The existing Order.total() callback accepts no guest object, so it requires no special handling.
Scoped callback parameters are the main interoperability rule applications need to account for when moving to separate heaps. Context creation, evaluation, and value invocation otherwise work as before.
With host capabilities, runtime failures, and cross-heap references accounted for, we can take the final step: stop trusting the rule source itself.
Running untrusted code
The best-understood example of untrusted JavaScript is the web browser. A browser downloads JavaScript from a website and executes it while assuming that the website may be malicious. The script may try to escape the JavaScript runtime, exploit the JIT compiler, exhaust resources, or misuse every capability available to it. It must not compromise the browser or gain arbitrary access to the local machine.
UNTRUSTED is designed for this adversarial model. In our example, the customer-supplied discount rule plays the role of website JavaScript, while the SaaS application plays the role of the browser.
So far, the application has owned the discount-rule source. Now assume that the source was submitted by a customer, loaded from an external database, or generated by AI. The Source itself must be treated as untrusted:
Source rule = Source.create("js", submittedRule);Creating a Source only represents the submitted program. It does not validate the program or make it trusted. The sandbox is established when the source is evaluated in an appropriately configured context. Although the API may allow it, running untrusted source under any policy other than UNTRUSTED is unsupported and not covered by the sandbox.
With ISOLATED, we trusted the rule and protected the host from bugs or malicious input. With UNTRUSTED, the attacker controls the source processed by the parser, runtime, and JIT compiler. The attacker can deliberately construct programs that consume resources, exercise unusual runtime paths, or shape the machine code emitted by the compiler.
UNTRUSTED retains the separate runtime, heap, garbage collector, and JIT compiler provided by ISOLATED. It then requires additional resource limits and enables compiler and runtime defenses designed for adversarial guest code:
double applyDiscount(Source rule, Order order) {
try (Context context = Context.newBuilder("js")
.sandbox(SandboxPolicy.UNTRUSTED)
.out(OutputStream.nullOutputStream())
.err(OutputStream.nullOutputStream())
.option("engine.MaxIsolateMemory", "64MB")
.option("sandbox.MaxHeapMemory", "32MB")
.option("sandbox.MaxCPUTime", "2s")
.option("sandbox.MaxASTDepth", "100")
.option("sandbox.MaxThreads", "1")
.option("sandbox.MaxOutputStreamSize", "64KB")
.option("sandbox.MaxErrorStreamSize", "64KB")
.build()) {
Value discount = context.eval(rule);
return discount.execute(order).asDouble();
}
}As with ISOLATED, an UNTRUSTED execution is canceled after the runtime detects that its configured CPU budget has been exceeded. GraalVM reports the failure with a PolyglotException for which isResourceExhausted() returns true. Once a resource limit has canceled a context, that context can no longer execute guest code.
Attacker-controlled source also makes the JIT compiler part of the attack surface. UNTRUSTED blinds constants embedded in generated machine code and randomizes compiled-function entry points, making the compiler output less predictable for JIT-spraying attacks. It also masks memory accesses and inserts speculative-execution barriers where masking is not applicable. Because the guest has its own JIT compiler, these defenses can be applied to guest code without imposing their cost on the host application’s compiler.
One final assumption remains: if an execution was contained successfully, one might consider the result trusted, but it is not. A hostile rule may return -1, NaN, or 1000 without ever escaping the sandbox. The application must verify that the discount is finite and between 0 and 1. The sandbox constrains how the rule executes; it does not decide whether its answer is meaningful.
Under both ISOLATED and UNTRUSTED, the guest runtime runs as an internal isolate inside the application process by default. Applications that require a separate address space and process boundary can use the external isolate mode with engine.IsolateMode=external. The child process has a separate address space and signal domain, so fatal guest failures no longer terminate the host process.
The sandbox limit values in this example are illustrative rather than production defaults. Production limits should be derived from representative workloads; see Configuring sandbox resource limits, including the use of sandbox.TraceLimits.
With the policy and its limits selected, the next decision is which scripts may share an engine.
Sharing code within a security domain
A SaaS application rarely runs one rule for one customer. It may run the same rules and libraries in many execution contexts across requests and tenants. Creating a separate engine for every context would needlessly parse and warm up that code repeatedly.
An explicit Engine defines the scope of code sharing. It owns the code cache: cached Source entries, parsed ASTs, profiling data, and optimized machine code. A Context owns an execution scope's application state, including global variables, guest objects, modules, and context-bound Value instances. Sharing an engine shares code, not application state.
By default, each context creates an implicit engine. An application opts into sharing by creating an explicit engine for a security domain and passing it to each context with Context.newBuilder("js").engine(domainEngine). The engine must be created with the domain's permitted languages, sandbox policy, default streams, and engine-level options such as engine.MaxIsolateMemory. Contexts attached to it must use compatible sandbox settings. The source can then be parsed once, and compilation and warmup can be amortized across contexts. This can reduce startup latency and memory when many contexts execute the same code.
Truffle languages, including Graal.js, implement context-independent code. This means the same JavaScript AST and optimized machine code can execute in different contexts without capturing the global variables or objects of the context that first ran it. This comes with a small peak-performance penalty: type checks, property accesses, and function or method calls cannot specialize as aggressively on context-specific object identities. When the same code runs in many contexts, avoiding repeated parsing and warmup typically outweighs that cost.
A security domain is a group of guest programs for which the application accepts sharing code artifacts and runtime resources. Programs in the same domain may all be untrusted relative to the host without being mutually distrusting. For example, one customer’s rules might share an engine, while rules from customers that must be isolated from one another use separate engines.
Press enter or click to view image in full size
All contexts sharing an engine use the engine’s sandbox policy. With isolate-backed execution, they also share the guest runtime, isolate heap, garbage collector, and JIT compiler. The hard engine.MaxIsolateMemory limit therefore applies to the domain's shared isolate. Context limits apply per context and generally accumulate across guest executions in that context. Scripts from different security domains need separate engines.
Bringing sandboxed extensions to production
Sandboxing has been a long-term focus for GraalVM. The Polyglot API, Truffle runtime, and GraalJS that provide its foundation have been used in production for many years. Within Oracle, NetSuite SuiteScript and Oracle Database MLE are longstanding users of these technologies. Picnic also uses GraalVM to run JavaScript and Python business rules from a Java backend, evaluating approximately 38 million rules per day, as described in Extending Java with Python and JavaScript at Picnic.
GraalVM is a good fit when a JVM host application needs to run JavaScript or WebAssembly close to its application data, expose a host API, and execute reusable code in many separate contexts, whether the host runs on the JVM or as a native executable built with Native Image. Native Image builds must include reachability metadata for host members exposed to guest code, as described under Configuring Native Host Reflection. GraalVM is particularly useful when scripts range from application-owned code to source submitted by untrusted users because the same Polyglot API supports progressively stronger capability, resource, and isolation boundaries. The same API can even be used for multiple languages.
In GraalVM 25.1, the sandbox policies support JavaScript and WebAssembly. Sandbox support for pure Python code without native extensions is under development.
GraalVM Community Edition 25.1 makes these sandboxing capabilities available to the broader GraalVM community. We look forward to seeing how developers use them to build, evaluate, and deploy sandboxed scripts. We welcome use cases, questions, and feedback through GitHub and the GraalVM Community Slack.
To get started, see the documentation for embedding languages, Polyglot Isolates, and sandboxing.
Happy sandboxing!