Running a WebAssembly Plugin Host Inside a MicroVM
There's a design meeting that happens at nearly every company that ships a plugin system, and it goes well. Someone says "we should let customers run their own logic," someone else says "absolutely not, that's arbitrary code execution," and then a third person says "WebAssembly" and the room relaxes. And they're right to relax, partly. Wasm is a genuinely good answer to a genuinely hard problem. It gives you a memory-safe execution sandbox with a linear memory the guest cannot escape by construction, instantiation cheap enough to do per-request, deterministic-ish semantics, and metering primitives that let you stop an infinite loop without killing a process. If you're evaluating ways to run untrusted logic in-process, Wasm should be on your shortlist and probably at the top of it.
I'm Ajay; I built PandaStack, which runs untrusted code in Firecracker microVMs for a living, and I want to make an argument that is not "Wasm is insecure." It isn't. The argument is narrower and, I think, more useful: Wasm isolates the guest program's memory from your process, and that is a real and valuable boundary — but it is almost never the boundary your plugin system actually depends on. The boundary you depend on is the set of host functions you exported, plus the correctness of the runtime implementing them. The Wasm sandbox is airtight around a program that can do nothing. The moment you make the program useful, you have punched holes in it deliberately, and those holes are now your security model.
What Wasm genuinely gives you
Let's be precise about the wins, because they're substantial and I don't want the rest of this post to read as dismissal. A Wasm module executes against a linear memory that is bounds-checked by the runtime. A pointer bug in the guest — a buffer overrun, a use-after-free, the whole classic C catalogue — corrupts the guest's own linear memory and nothing else. It cannot read your process's heap, cannot walk your stack, cannot reach a struct you'd rather it didn't. Compare that to `dlopen`ing a customer's shared object, which is the thing plugin systems used to do and which offers a security model best described as "we trust them, and also we have no choice."
You also get control flow integrity for free — indirect calls go through a typed function table, so the ROP-gadget playbook doesn't apply. You get instantiation that lives in the microseconds-to-milliseconds class rather than the process-spawn class, which is why per-request plugin invocation is even thinkable. Runtimes like wasmtime give you fuel or epoch interruption, so a plugin that spins forever burns its allowance and traps instead of pinning a core until someone pages you. And you get a portable artifact: one `.wasm` runs on every architecture you deploy to, which quietly removes an entire category of build infrastructure.
- Memory safety by construction — the guest's linear memory is bounds-checked, so guest memory corruption stays inside the guest.
- Control flow integrity — indirect calls are type-checked through a function table rather than jumping to arbitrary addresses.
- Cheap instantiation — microseconds-to-milliseconds class on mature runtimes, cheap enough for per-request plugin calls. Measure your own runtime and module rather than trusting anyone's blog number, including mine.
- Metering — fuel or epoch-based interruption lets you bound CPU consumption per instance and trap cleanly instead of SIGKILLing a process.
- Deterministic-ish execution — no ambient nondeterminism unless you export it, which makes replay and testing dramatically easier.
- Portability — one artifact, every architecture, no per-platform build matrix for plugin authors.
That is a real list. If your plugins are pure functions — transform this JSON, score this record, evaluate this rule against this input, render this template — Wasm alone is a defensible and probably correct answer, and you can stop reading around the "when Wasm alone is fine" section. The trouble starts when "pure function" stops being true, which in my experience takes about one quarter.
The boundary is your host function surface
A Wasm module with no imports is inert. It computes, it returns, it cannot observe or affect anything. That's the sandbox everyone pictures when they say "Wasm is sandboxed," and it is completely accurate for that module. But nobody ships that module. Plugins exist to do things, so you export host functions: read a config file, call an HTTP endpoint, look up a record, emit a log line, fetch a secret. Each export is a hand-drilled hole in a wall you just praised for being solid.
Here's the part that trips up otherwise careful people. The Wasm boundary is enforced at the call site — the guest can only call what you imported, with the types you declared. It is not enforced inside your host function. Once execution crosses into your Rust or Go code, you are running with your process's full ambient authority: your file descriptors, your network namespace, your credentials, your database connection pool. Wasm sandboxes the plugin from your process's memory; it does not sandbox the plugin from the function you helpfully named `exec_sql`. If that function takes a string and hands it to a connection that authenticates as your application, you have built a very fast, very memory-safe pipe to your production database.
The failure modes here are boring and old, which is exactly why they keep happening. A `read_file` host function that takes a path and does no normalization gives you path traversal — a problem we collectively solved in 1998 and then re-shipped inside a shiny new sandbox. An `http_fetch` that accepts any URL gives you SSRF straight at your cloud metadata endpoint, and the plugin doesn't need to escape anything to do it: it's using the API exactly as designed. A `log` function that concatenates guest strings into a structured sink gives you log injection. A key-value handle scoped to the tenant is fine right up until someone adds a `list_keys` variant for debugging and forgets the scoping. None of these are Wasm bugs. All of them are shipped.
WASI, and why capabilities are only as good as what you hand out
WASI's answer to this is capability-based security, and it is architecturally the right answer. Instead of ambient authority — where any code can open any path because the process can — the guest gets explicit handles it was granted. A preopened directory means the guest can touch that subtree and nothing else. No handle, no access. It's the same discipline as capability-based OS research, applied to a runtime people actually deploy, and it's a genuine improvement over "the plugin runs as your service account."
But capability systems have a well-known property, which is that they push the security decision from the runtime to the person configuring it. The runtime enforces exactly what you granted, perfectly and without judgment. So when someone preopens `/` because a plugin author filed a ticket saying their module can't find its data file, WASI does precisely what it was told. When outbound network access is granted as "can make HTTP requests" rather than "can reach these three hosts," the capability is technically scoped and practically unbounded. Capability-based security converts a runtime problem into a configuration problem, and configuration problems are resolved by tired humans under delivery pressure, which is not a threat model I'd bet a multi-tenant platform on by itself.
What Wasm does not give you
Beyond the host function surface, there are four categories of thing the Wasm boundary structurally does not address. Worth naming them individually, because they're the ones people are surprised by during an incident rather than during a design review.
- Runtime CVEs. Wasm runtimes are large, fast-moving, JIT-compiling programs written by careful people who are nonetheless human. Sandbox escapes have been found in every major runtime and will be found again. Your plugin isolation is exactly as good as your patch latency on a dependency you probably do not track as closely as your own code.
- Side channels. Wasm defines nothing about cache behavior, branch prediction, or timing. A plugin sharing a core with your request handler shares microarchitectural state with it. Spectre-class attacks are not defeated by bounds checks; the runtime is executing on the same silicon as everything else in your process.
- Host process resource exhaustion. Fuel bounds CPU inside an instance. It doesn't bound the memory your host functions allocate on the guest's behalf, the file descriptors you open for it, the connections you pool for it, or the thousand instances a burst of traffic creates simultaneously. The guest is metered; your process is not.
- Any kernel boundary at all. A Wasm sandbox escape lands you in the host process, running as that process, on that host, with that host's kernel and that host's credentials. If that process serves multiple tenants — and if you built a plugin platform, it does — the blast radius of one escape is every tenant on the box.
That last one is the structural point, and it's not a criticism of Wasm — it's a description of the layer Wasm operates at. Wasm is an in-process isolation mechanism. It is very good at being an in-process isolation mechanism. But "in-process" means that when it fails, it fails into your process, and your process is the thing holding your credentials. There is no second wall. The mitigation isn't a better Wasm runtime; it's a wall at a different layer.
The host function that is too powerful (a worked example)
Here's a wasmtime host that does the metering part correctly — fuel limits, epoch deadlines, a memory ceiling, no WASI preopens by default — and then exports one host function that quietly undoes the effort. I've seen versions of this in real code more than once, usually with a comment above it saying it's temporary.
use anyhow::Result;
use wasmtime::*;
struct HostCtx {
tenant_id: String,
// The connection pool authenticates as YOUR service, not as the tenant.
db: sqlx::PgPool,
limits: StoreLimits,
}
fn main() -> Result<()> {
let mut cfg = Config::new();
cfg.consume_fuel(true); // meter CPU
cfg.epoch_interruption(true); // wall-clock deadline
let engine = Engine::new(&cfg)?;
let ctx = HostCtx {
tenant_id: "acme".into(),
db: pool(),
limits: StoreLimitsBuilder::new()
.memory_size(64 << 20) // 64 MiB linear memory ceiling
.instances(1)
.build(),
};
let mut store = Store::new(&engine, ctx);
store.limiter(|c| &mut c.limits);
store.set_fuel(50_000_000)?; // plugin cannot spin forever
store.set_epoch_deadline(1); // ...or block forever
let mut linker = Linker::new(&engine);
// ---------------------------------------------------------------
// Everything above is correct and worth doing. Everything below
// makes it decorative.
// ---------------------------------------------------------------
linker.func_wrap(
"host",
"query",
|mut caller: Caller<'_, HostCtx>, ptr: i32, len: i32| -> i32 {
let sql = read_guest_string(&mut caller, ptr, len);
// The Wasm sandbox is intact. The guest never left its linear
// memory. It simply asked us, politely, through the front door.
let rows = block_on(sqlx::query(&sql).fetch_all(&caller.data().db));
write_guest_json(&mut caller, rows)
},
)?;
// Same shape, different blast radius: an unrestricted fetch is an
// SSRF primitive aimed at 169.254.169.254 by whoever asks first.
linker.func_wrap(
"host",
"fetch",
|mut caller: Caller<'_, HostCtx>, ptr: i32, len: i32| -> i32 {
let url = read_guest_string(&mut caller, ptr, len);
let body = block_on(reqwest::get(&url)).and_then(|r| block_on(r.text()));
write_guest_string(&mut caller, body.unwrap_or_default())
},
)?;
let module = Module::from_file(&engine, "plugin.wasm")?;
let instance = linker.instantiate(&mut store, &module)?;
let run = instance.get_typed_func::<(), ()>(&mut store, "run")?;
run.call(&mut store, ())?;
Ok(())
}Read that and notice what an attacker has to do: nothing clever. No heap grooming, no JIT bug, no type confusion. They call `host.query` with a string. The fuel meter counts down honestly, the linear memory stays within its 64 MiB, the runtime behaves exactly as documented, and every security property Wasm promised is fully upheld while your database is read by a stranger. This is what I mean when I say the Wasm boundary is usually not the boundary. The exploit path went around it, through a door marked "entrance."
The correct fix is obvious in hindsight and tedious in practice: `query` should not take SQL. It should take a named, parameterized query the host controls, tenant-scoped in the host, with the tenant identifier coming from `HostCtx` and never from guest memory. `fetch` should take a host from an allowlist, resolve DNS itself, reject anything that resolves to a private or link-local range, and refuse redirects that leave the allowlist. That's the real work of a Wasm plugin system, and it's all work you do on the host side, in your own language, where Wasm cannot help you. Which is fine — but it means the audit surface is your code, not the runtime's.
Put the whole Wasm host inside a microVM
So here's the layered move, and the reason I wrote this post. Keep Wasm. It's doing real work and it's cheap. But stop treating the Wasm host process as something that runs on your multi-tenant application server, and start treating it as untrusted code in its own right — because functionally, it is. It's a process whose entire job is to execute strangers' logic and expose an attack surface you wrote by hand under deadline. Then run that whole process inside a Firecracker microVM.
What changes is where a failure lands. Runtime CVE gets exploited? The attacker now has code execution inside a guest kernel on a disposable VM that holds one tenant's data, has no cloud credentials, and gets deleted in a moment. Host function has an SSRF bug? The fetch originates from a network namespace whose egress rules you control at the host, not from a pod with a service account attached. Plugin exhausts memory in a way fuel didn't catch? It exhausts the VM's baked RAM allocation and the VM dies, and the other tenants — in their own VMs, on their own guest kernels — never notice. The Wasm layer still does the cheap per-plugin isolation it's good at. The VM layer is the wall you'd actually bet on.
- Isolation mechanism — Wasm alone: bounds-checked linear memory and a typed import table, enforced by the runtime in your process. Wasm inside a microVM: the same, plus hardware virtualization with a separate guest kernel and a minimal virtual device surface.
- Blast radius of a runtime escape — Wasm alone: your host process, its credentials, and every tenant it serves. Wasm inside a microVM: one disposable guest that you delete.
- Host function abuse (SSRF, path traversal, over-broad handles) — Wasm alone: reaches whatever your process can reach, which is usually production. Wasm inside a microVM: reaches whatever that guest's network namespace permits, which you configure once, centrally.
- Resource exhaustion of the host process — Wasm alone: fuel bounds the guest's CPU; nothing bounds the memory, FDs, or connections your host functions allocate on its behalf. Wasm inside a microVM: bounded by the VM's own vCPU and RAM, enforced by the hypervisor rather than by your discipline.
- Side channels against co-tenants — Wasm alone: plugins share microarchitectural state with your request handlers. Wasm inside a microVM: a separate VM, and you can go further and not co-schedule tenants on a core.
- Startup cost — Wasm alone: instantiation in the microseconds-to-milliseconds class, genuinely faster; per-request instantiation is realistic. Wasm inside a microVM: snapshot restore around 49ms, p50 179ms end-to-end create, so per-tenant or per-session rather than per-call.
- Language support — Wasm alone: whatever compiles to Wasm cleanly, which is still a real constraint for some ecosystems. Wasm inside a microVM: the VM runs anything; Wasm remains an inner-layer choice you can vary per plugin.
- What you have to get right — Wasm alone: every host function, forever, with no second chance. Wasm inside a microVM: still every host function — but a mistake is contained rather than terminal.
The objection to this used to be cost, and it was a fair objection. "A VM per tenant" meant multi-second boots and gigabytes reserved up front, which is a terrible fit for a plugin system whose whole appeal is cheap execution. Snapshot-restore changes the arithmetic: PandaStack creates a sandbox by restoring a baked snapshot rather than cold-booting, so the restore step lands around 49ms, end-to-end create is p50 179ms and p99 around 203ms, and only the first-ever cold boot of a template runs about 3 seconds. Memory restores copy-on-write and the rootfs is a reflink clone, so the hundredth VM isn't copying gigabytes off disk. There are 16,384 pre-allocated network slots per agent, so networking isn't the bottleneck either. A VM per tenant, per session, or per risky plugin becomes an ordinary thing to do rather than a capacity planning exercise.
from pandastack import Sandbox
# One microVM per tenant plugin session. The Wasm host process and every
# plugin it loads live in here; the guest kernel is the trust boundary.
sbx = Sandbox.create(template="base", ttl_seconds=900)
try:
# Ship the tenant's compiled plugin into the guest. It never touches
# a filesystem shared with any other tenant.
with open("tenants/acme/plugin.wasm", "rb") as f:
sbx.filesystem.write("/work/plugin.wasm", f.read())
# Only the data this plugin is allowed to see goes in with it.
sbx.filesystem.write("/work/input.json", tenant_scoped_payload())
# Run the Wasm host. Note the belt-and-braces: fuel + a wall-clock
# limit inside the guest, AND a timeout on the exec, AND a TTL on the
# VM. Any one of them failing is survivable because the others exist.
r = sbx.exec(
"cd /work && wasm-host "
"--module plugin.wasm "
"--input input.json "
"--fuel 50000000 "
"--deadline-ms 5000 "
"--allow-net none " # no egress unless this tenant paid for it
"--preopen /work/data", # ...and nothing above that directory
timeout_seconds=30,
)
if r.exit_code != 0:
# A trap, an OOM, or a fuel exhaustion all land here. None of them
# are your problem to clean up: the machine is about to be deleted.
log.warning("plugin failed exit=%s stderr=%s", r.exit_code, r.stderr[-2000:])
raise PluginFailed(r.exit_code)
result = r.stdout
finally:
# Delete the whole machine. Not a container teardown, not a runtime
# reset — the kernel and everything that ran under it are gone.
sbx.kill()The `finally` block is doing more work than it looks like. When the isolation unit is a whole machine, cleanup is total — there is no leaked file descriptor, no half-written temp file, no runtime state a reset function forgot about, no lingering connection in a pool. The guest kernel that the plugin was running under ceases to exist. Compare that to resetting a Wasm store in a long-lived process, where correctness depends on your reset actually covering everything the plugin touched, which is a claim you can only make about the parts you remembered.
The layered model, concretely
The two layers aren't redundant; they're doing different jobs at different price points, and that's exactly what defense in depth is supposed to look like. Wasm gives you cheap, fast, fine-grained isolation between plugins — you can instantiate one per call, per user, per rule evaluation, and the cost stays sane. The microVM gives you a coarse, slow, extremely strong boundary between tenants, or between the plugin system and everything else you own. Use the cheap layer at high frequency and the strong layer at the frequency where a breach would actually matter.
- Group plugins by trust and blast radius, not by convenience. One tenant's plugins can share a VM with each other; two tenants' plugins should not share one.
- One microVM per tenant session, created on demand and killed when the session ends. At snapshot-restore speeds you don't need to pool them, and pooling is where cross-tenant leaks come from.
- Inside that VM, one Wasm instance per invocation, with fuel and an epoch deadline. This is your fast inner loop and where the per-call cost lives.
- Keep host functions narrow anyway. The VM means a mistake is contained, not that mistakes are free — a leaked tenant secret is still leaked even if the VM dies afterward.
- Put egress policy at the VM's network namespace, not only in your `fetch` implementation. Two independent enforcement points, one of which is not written in the language your bugs are in.
- Give the VM only the data that plugin is allowed to see. The strongest access control is not having the bytes on the machine.
- Set a TTL on every VM. A plugin session that hangs should cost you a machine that reaps itself, not a support ticket in three weeks about a mystery instance.
Defense in depth isn't two locks on the same door. It's a lock on the door and a wall around the building — and the wall doesn't care whether the lock was picked or you left the key under the mat.
When Wasm alone is genuinely fine
I'd rather you use the right amount of machinery than the maximum amount, so let me draw the line honestly. If your plugins are pure computation over data you already handed them — a scoring function, a JSON transform, a template render, a feature flag rule, a validation predicate — with zero host functions or only host functions that read from a buffer you populated in advance, then Wasm alone is a correct answer. The blast radius of a total escape is a process that holds one request's worth of data and no credentials. Adding a VM there buys you a rounding error of safety at a real cost in latency and complexity, and I'd tell you not to bother.
The same applies when the code is genuinely first-party and Wasm is being used for portability or hot-reload rather than security — plenty of teams use Wasm as a plugin ABI for their own code, which is a fine engineering decision that has nothing to do with threat models. And it applies at the edge, where you're already inside someone else's isolation architecture and the per-request budget is measured in milliseconds; there, the platform operator is running the strong boundary on your behalf, which is the same layered model with a different bill payer.
The threshold to watch for is host functions that reach outside the invocation. The first time someone adds an import that touches a network, a filesystem, a database, or a credential, the security model quietly changed and probably nobody wrote it down. That's the moment the question stops being "is Wasm secure" and becomes "what does this process have access to, and would I be comfortable if a stranger's code were running with all of it." If the answer makes you uncomfortable, the fix isn't a stricter runtime — it's a kernel between that process and everything you care about. Keep the Wasm. It's good. Just don't ask it to be the only thing standing between a customer's plugin and your production environment, because that was never the job it was designed for.
Frequently asked questions
Is WebAssembly secure enough to run untrusted plugin code?
For the code itself, largely yes — a Wasm module executes against a bounds-checked linear memory it cannot escape, indirect calls are type-checked through a function table, and runtimes like wasmtime let you bound CPU with fuel or epoch interruption. What Wasm does not secure is the set of host functions you export to make the plugin useful. Those run in your process with your process's full ambient authority: your credentials, your network access, your file descriptors. A plugin that calls a host function you exported has not escaped anything; it is using your API exactly as designed. So the honest answer is that Wasm secures the guest program, and you are responsible for securing every hole you deliberately drilled through the wall.
What is the difference between WASI capabilities and just sandboxing?
WASI replaces ambient authority with explicit handles: instead of the guest being able to open any path because the host process can, it can only touch a directory you preopened and only reach a network you granted. Architecturally this is the right model and a real improvement over running plugins as your service account. The catch is that capability systems move the security decision from the runtime to whoever configures it. The runtime enforces exactly what you granted, without judgment — so preopening the filesystem root because a plugin author filed a ticket, or granting generic outbound HTTP rather than three specific hosts, produces a technically-scoped and practically-unbounded capability. Capabilities turn a runtime problem into a configuration problem, and configuration problems get resolved by tired people under delivery pressure.
Why run a Wasm runtime inside a microVM if Wasm is already a sandbox?
Because they are boundaries at different layers, and the Wasm one is in-process. When a Wasm sandbox fails — a runtime CVE, a JIT bug, or more commonly an over-permissive host function — the failure lands in your host process, running as that process, with that process's credentials, on a host that probably serves other tenants. There is no second wall. Running the entire Wasm host inside a Firecracker microVM means the failure lands in a disposable guest kernel that holds one tenant's data and no cloud credentials, and you delete the machine. The Wasm layer keeps doing the cheap per-plugin isolation it is genuinely good at; the VM layer is the boundary you would actually bet a multi-tenant platform on.
Doesn't adding a microVM make plugin execution too slow?
It adds latency, and you should be honest about where. Wasm instantiation is genuinely fast — the microseconds-to-milliseconds class on mature runtimes, though you should measure your own runtime and module rather than trusting anyone's published figure. A microVM is a different order: on PandaStack the snapshot-restore step is around 49ms with end-to-end create at p50 179ms and p99 roughly 203ms, and only a template's first-ever cold boot runs about 3 seconds. That makes VMs the wrong unit for per-call isolation and the right unit for per-tenant or per-session isolation. The layered model uses each at its natural frequency: one VM per tenant session, and inside it, one cheap Wasm instance per invocation.
When is Wasm alone genuinely enough, with no VM?
When your plugins are pure computation over data you already handed them, with no host functions or only host functions that read from a buffer you populated in advance. Scoring functions, JSON transforms, template rendering, feature flag rules, validation predicates — for all of these the blast radius of a total sandbox escape is a process holding one request's worth of data and no credentials, and a VM would buy you a rounding error of safety for real latency and complexity. The same holds when Wasm is a plugin ABI for your own first-party code rather than a security boundary. The threshold to watch is the first host function that reaches outside the invocation to a network, a filesystem, a database, or a credential — that is when the security model silently changed and the question becomes what a stranger's code could do with everything your process can reach.
49ms p50 cold start. Fork, snapshot, and scale to zero.