{"schemaVersion":1,"id":"https://lucioduran.com/blog/capability-based-security-wasi-preview2","url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2","title":"Capability-Based Security with WASI Preview 2: Least-Privilege Plugin Systems at the Runtime Level","description":"How WASI Preview 2's capability-based model enforces least-privilege at the runtime level. Filesystem sandboxing, network capabilities, wasi-http, and production-grade secure plugin architectures.","language":"en","datePublished":"2025-11-25","dateModified":null,"contentFormat":"text/markdown","content":"Security\n\n# Capability-Based Security with WASI Preview 2: Least-Privilege Plugin Systems at the Runtime Level\n\n2025-11-257 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/capability-based-security-wasi-preview2>)\n\n## The Capability Model: From ACLs to Object Capabilities\n\nTraditional operating systems use Access Control Lists. A process has a user ID, and the kernel checks that user ID against file permissions. The problem is ambient authority, a process can attempt to access any resource, and the kernel decides yes or no after the fact. This means a compromised process inherits all the permissions of its user.\n\nCapability-based security inverts this. Instead of checking \"who are you and what are you allowed to do?\", the runtime says \"here are handles to exactly the resources you can use, and nothing else exists.\" If you don't have a handle to the filesystem, you can't even attempt to open a file. There's no ` open(\"/etc/passwd\") ` that returns \"permission denied\", the function ` open ` simply doesn't exist in your universe.\n\nWASI Preview 2 implements this through the Component Model and WIT (WebAssembly Interface Types):\n\n```wit\n// This is a WIT world definition, it defines what a plugin can do\nworld data-transformer {\n // What the plugin receives from the host\n import wasi:io/streams@0.2.1;\n import wasi:filesystem/types@0.2.1;\n import wasi:filesystem/preopens@0.2.1;\n import wasi:logging/logging@0.1.0;\n\n // What the plugin must implement\n export transform: func(input: list<u8>) -> result<list<u8>, string>;\n}\n```\n\nA component compiled against this world literally cannot make HTTP requests, because ` wasi:http ` isn't in its imports. It can't access the clock, can't spawn threads, can't open sockets. The Wasm runtime enforces this at link time, if a component tries to import an interface that isn't in its world, instantiation fails before a single instruction executes.\n\n## Filesystem Sandboxing: Preopens and Directory Handles\n\nWASI's filesystem model deserves special attention because it's where most plugin systems fail. In WASI Preview 2, a component doesn't get access to a filesystem, it gets handles to specific directories that the host \"preopens\":\n\n```rust\n// Host-side: configuring what the plugin can see\nuse wasmtime::component::*;\nuse wasmtime_wasi::{WasiCtxBuilder, DirPerms, FilePerms};\n\nlet mut wasi_ctx = WasiCtxBuilder::new();\n\n// Plugin gets read-only access to /data/input\nwasi_ctx.preopened_dir(\n \"/data/input\",\n \"input\",\n DirPerms::READ,\n FilePerms::READ,\n)?;\n\n// Plugin gets read-write access to /data/output\nwasi_ctx.preopened_dir(\n \"/data/output\",\n \"output\",\n DirPerms::all(),\n FilePerms::all(),\n)?;\n\n// That's it. No other filesystem access exists.\n```\n\nInside the plugin, the filesystem looks like this:\n\n```rust\n// Plugin-side: this is the entire visible filesystem\n// \"input/\" → maps to /data/input on the host (read-only)\n// \"output/\" → maps to /data/output on the host (read-write)\n\nfn transform(data: Vec<u8>) -> Result<Vec<u8>, String> {\n // This works, \"input\" is a preopened directory\n let config = std::fs::read_to_string(\"input/config.json\")\n .map_err(|e| e.to_string())?;\n\n // This works, \"output\" is preopened with write access\n std::fs::write(\"output/result.csv\", &processed_data)\n .map_err(|e| e.to_string())?;\n\n // This would fail at runtime, no preopen for \"/\"\n // std::fs::read_to_string(\"/etc/passwd\") // ERROR: no such preopen\n\n // This would fail, no preopen for \"..\"\n // std::fs::read_to_string(\"../../../etc/shadow\") // ERROR: path escapes preopen\n\n Ok(processed_data)\n}\n```\n\nPath traversal attacks (` ../../../etc/passwd `) are structurally impossible because the runtime resolves paths relative to preopens and rejects anything that escapes. This isn't a string filter on paths, it's enforced at the directory handle level.\n\n## Network Capabilities: wasi:http\n\nFor plugins that need to make outbound HTTP requests (fetching exchange rates, calling APIs, etc.), WASI Preview 2 provides ` wasi:http/outgoing-handler `. The critical design decision is that the host controls what the plugin can connect to:\n\n```rust\n// Host-side: creating an HTTP capability with domain allowlist\nuse wasmtime_wasi_http::WasiHttpCtx;\n\nstruct PluginHttpHandler {\n allowed_domains: HashSet<String>,\n}\n\nimpl PluginHttpHandler {\n fn handle_request(&self, request: OutgoingRequest) -> Result<IncomingResponse> {\n let authority = request.authority()\n .ok_or_else(|| anyhow!(\"missing authority\"))?;\n\n // Enforce domain allowlist\n if !self.allowed_domains.contains(&authority) {\n return Err(anyhow!(\n \"plugin attempted to connect to unauthorized domain: {}\",\n authority\n ));\n }\n\n // Forward the request to the actual HTTP client\n self.inner_client.send(request)\n }\n}\n\n// Plugin only gets access to specific domains\nlet http_handler = PluginHttpHandler {\n allowed_domains: HashSet::from([\n \"api.exchangerate.host\".into(),\n \"data.provider.com\".into(),\n ]),\n};\n```\n\nFrom the plugin's perspective, it just makes HTTP requests. It doesn't know about the allowlist, a request to an unauthorized domain simply fails:\n\n```rust\n// Plugin-side: making HTTP requests\nuse wasi::http::outgoing_handler;\n\nfn fetch_exchange_rate(currency: &str) -> Result<f64, String> {\n let request = OutgoingRequest::new(\n Method::Get,\n Some(\"api.exchangerate.host\"),\n Some(&format!(\"/latest?base={}\", currency)),\n Some(Scheme::Https),\n );\n\n let response = outgoing_handler::handle(request, None)\n .map_err(|e| format!(\"HTTP request failed: {:?}\", e))?;\n\n // Parse response...\n Ok(rate)\n}\n```\n\n## Building a Secure Plugin Runtime\n\nThe following architecture demonstrates a production-grade approach. The key insight is that each plugin invocation gets a fresh component instance with a tailored capability set:\n\n```rust\nuse wasmtime::*;\nuse wasmtime::component::*;\nuse wasmtime_wasi::*;\n\nstruct PluginRuntime {\n engine: Engine,\n linker: Linker<PluginState>,\n plugin_components: HashMap<String, Component>,\n}\n\nimpl PluginRuntime {\n fn execute_plugin(\n &self,\n plugin_id: &str,\n tenant_id: &str,\n input: &[u8],\n ) -> Result<Vec<u8>> {\n let component = self.plugin_components.get(plugin_id)\n .ok_or_else(|| anyhow!(\"unknown plugin\"))?;\n\n // Each invocation gets isolated state\n let mut store = Store::new(&self.engine, PluginState {\n wasi: self.build_wasi_ctx(tenant_id)?,\n fuel_remaining: 1_000_000_000, // CPU budget\n memory_limit: 256 * 1024 * 1024, // 256MB\n });\n\n // Fuel metering prevents infinite loops\n store.set_fuel(1_000_000_000)?;\n\n let instance = self.linker.instantiate(&mut store, component)?;\n\n // Call the exported transform function\n let transform = instance.get_typed_func::<(Vec<u8>,), (Result<Vec<u8>, String>,)>(\n &mut store, \"transform\"\n )?;\n\n let (result,) = transform.call(&mut store, (input.to_vec(),))?;\n result.map_err(|e| anyhow!(\"plugin error: {}\", e))\n }\n\n fn build_wasi_ctx(&self, tenant_id: &str) -> Result<WasiCtx> {\n let mut builder = WasiCtxBuilder::new();\n\n // Tenant-isolated filesystem\n builder.preopened_dir(\n &format!(\"/data/tenants/{}/input\", tenant_id),\n \"input\",\n DirPerms::READ,\n FilePerms::READ,\n )?;\n\n builder.preopened_dir(\n &format!(\"/data/tenants/{}/output\", tenant_id),\n \"output\",\n DirPerms::all(),\n FilePerms::all(),\n )?;\n\n // No network, no clock, no env vars, no args\n // stdout/stderr captured for logging\n builder.stdout(LogCapture::new(tenant_id));\n builder.stderr(LogCapture::new(tenant_id));\n\n Ok(builder.build())\n }\n}\n```\n\n## Component Composition: Virtualizing Capabilities\n\nOne of Preview 2's most powerful features is component composition. You can interpose a component between the plugin and the host to transform or restrict capabilities without modifying either:\n\n```wit\n// A logging virtualization component that redacts sensitive data\nworld logging-filter {\n import wasi:logging/logging@0.1.0;\n export wasi:logging/logging@0.1.0;\n}\n```\n\n```rust\n// The filter component intercepts all log calls\nimpl wasi::logging::Logging for LogFilter {\n fn log(level: Level, context: &str, message: &str) {\n // Redact anything that looks like a credit card number\n let redacted = CREDIT_CARD_REGEX.replace_all(message, \"[REDACTED]\");\n // Forward to the real logging implementation\n wasi::logging::log(level, context, &redacted);\n }\n}\n```\n\nYou compose these at link time:\n\n```bash\n# Compose plugin with logging filter\nwasm-tools compose plugin.wasm \\\n --definitions logging-filter.wasm \\\n -o composed-plugin.wasm\n```\n\nThe resulting component has identical behavior from the plugin's perspective, but all logging goes through the filter. The plugin can't bypass it because the composition is enforced structurally.\n\n## Performance Reality Check\n\nOverhead is a common concern. Here are real numbers from a production system:\n\n```\nPlugin instantiation: ~50μs (component model, Wasmtime 28)\nSimple transform (1KB input): ~120μs total execution\nComplex transform (1MB input): ~8ms total execution\nMemory overhead per instance: ~2MB baseline + plugin allocation\n\nFor comparison:\nDocker container cold start: ~200ms-2s\nV8 isolate creation: ~5ms\nLua state creation: ~100μs (but no memory safety)\n```\n\nThe 50-microsecond instantiation means a fresh, fully-isolated plugin instance can be created per request without batching. That eliminates an entire class of state-leakage bugs between requests.\n\n## Lessons From Production Deployments\n\nSeveral lessons emerge from extended production usage:\n\n**Fuel metering is essential.** Without it, a plugin with an accidental infinite loop blocks a worker thread forever. Setting fuel limits based on the expected complexity of each plugin type and killing instances that exhaust their budget is critical.\n\n**Log capture prevents information leakage.** Plugins write to stdout/stderr thinking they're debugging, but in a multi-tenant system, those logs need to go to the right tenant's log stream. Capturing stdout via the WASI context handles this cleanly.\n\n**Pre-compile components.** Wasmtime supports ahead-of-time compilation with ` wasmtime compile `. A pre-compiled component loads in ~5μs instead of ~50μs. For hot-path plugins, this matters.\n\n```bash\n# Pre-compile for production deployment\nwasmtime compile --target x86_64-unknown-linux plugin.wasm -o plugin.cwasm\n```\n\n**Version WIT interfaces.** This lesson becomes apparent when a WIT interface update breaks plugins that were compiled against the old version. The component model's version matching prevents silent breakage, but you still need to maintain backward-compatible interfaces.\n\nThe capability-based security model in WASI Preview 2 is not merely a theoretical property, it is a practical engineering tool that eliminates entire categories of security bugs. When a plugin system literally cannot access resources it was not granted, the security posture improves dramatically. Compared to building sandboxes from cgroups and seccomp filters alone, the reduction in attack surface is substantial.\n\n## Frequently asked questions\n\n### How is WASI Preview 2 different from WASI Preview 1?\n\nPreview 1 used a POSIX-like API with file descriptors. Preview 2 replaces this with the Component Model, where capabilities are expressed as typed interfaces (WIT). Instead of file descriptor numbers, you get strongly-typed handles to specific resources. It's also composable, you can virtualize any capability by interposing a component.\n\n### Can a WASI component escape its sandbox?\n\nNot through the WASI API surface. A component can only access capabilities that were explicitly granted by the host at instantiation time. There's no equivalent of opening /etc/passwd because the filesystem capability literally doesn't include that path. The only attack surface is the Wasm runtime itself (memory safety bugs in Wasmtime, etc.).\n\n### Is WASI Preview 2 production-ready?\n\nAs of early 2026, Wasmtime, WAMR, and Jco all support WASI Preview 2 with the component model. Cloudflare Workers uses a subset. It's production-ready for plugin systems and edge compute. For general-purpose server workloads, the ecosystem is still maturing, some interfaces like wasi:sockets are stabilizing.\n\n## Tips\n\n1. **Tip:** Never grant ` wasi:filesystem/preopens ` with write access to directories your plugin doesn't absolutely need. The whole point of capabilities is deny-by-default, if you grant everything because it's easier during development, you've built a sandbox with no walls.\n2. **Tip:** Use ` wasi:http/outgoing-handler ` instead of raw socket capabilities. It lets you enforce allowlists of target domains at the host level, so a compromised plugin can't phone home to ` evil-c2-server.ru ` even if it tries.\n3. **Tip:** When testing WASI components, use ` wasmtime serve --addr 127.0.0.1:8080 ` with the ` -S common ` flag to get sensible default capabilities. But in production, always define an explicit capability set, implicit defaults are a security liability.\n4. **Tip:** Pin your WIT interface versions. A plugin compiled against ` wasi:http@0.2.1 ` won't accidentally gain capabilities from ` wasi:http@0.2.3 ` because the component model enforces exact version matching at link time.\n\n- wasi\n- webassembly\n- capability-based-security\n- wasm\n- sandboxing\n- plugin-systems\n- wasi-http\n- component-model\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Capability-Based Security with WASI Preview 2: Least-Privilege Plugin Systems at the Runtime Level","url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#title"},{"level":2,"text":"The Capability Model: From ACLs to Object Capabilities","url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#the-capability-model-from-acls-to-object-capabilities"},{"level":2,"text":"Filesystem Sandboxing: Preopens and Directory Handles","url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#filesystem-sandboxing-preopens-and-directory-handles"},{"level":2,"text":"Network Capabilities: wasi:http","url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#network-capabilities-wasihttp"},{"level":2,"text":"Building a Secure Plugin Runtime","url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#building-a-secure-plugin-runtime"},{"level":2,"text":"Component Composition: Virtualizing Capabilities","url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#component-composition-virtualizing-capabilities"},{"level":2,"text":"Performance Reality Check","url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#performance-reality-check"},{"level":2,"text":"Lessons From Production Deployments","url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#lessons-from-production-deployments"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#questions"},{"level":3,"text":"How is WASI Preview 2 different from WASI Preview 1?","url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#question-1"},{"level":3,"text":"Can a WASI component escape its sandbox?","url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#question-2"},{"level":3,"text":"Is WASI Preview 2 production-ready?","url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#article-tips"}],"sections":[{"heading":"Capability-Based Security with WASI Preview 2: Least-Privilege Plugin Systems at the Runtime Level","level":1,"url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2","content":"Security"},{"heading":"Capability-Based Security with WASI Preview 2: Least-Privilege Plugin Systems at the Runtime Level","level":1,"url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#title","content":"2025-11-257 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/capability-based-security-wasi-preview2>)"},{"heading":"The Capability Model: From ACLs to Object Capabilities","level":2,"url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#the-capability-model-from-acls-to-object-capabilities","content":"Traditional operating systems use Access Control Lists. A process has a user ID, and the kernel checks that user ID against file permissions. The problem is ambient authority, a process can attempt to access any resource, and the kernel decides yes or no after the fact. This means a compromised process inherits all the permissions of its user.\n\nCapability-based security inverts this. Instead of checking \"who are you and what are you allowed to do?\", the runtime says \"here are handles to exactly the resources you can use, and nothing else exists.\" If you don't have a handle to the filesystem, you can't even attempt to open a file. There's no ` open(\"/etc/passwd\") ` that returns \"permission denied\", the function ` open ` simply doesn't exist in your universe.\n\nWASI Preview 2 implements this through the Component Model and WIT (WebAssembly Interface Types):\n\n```wit\n// This is a WIT world definition, it defines what a plugin can do\nworld data-transformer {\n // What the plugin receives from the host\n import wasi:io/streams@0.2.1;\n import wasi:filesystem/types@0.2.1;\n import wasi:filesystem/preopens@0.2.1;\n import wasi:logging/logging@0.1.0;\n\n // What the plugin must implement\n export transform: func(input: list<u8>) -> result<list<u8>, string>;\n}\n```\n\nA component compiled against this world literally cannot make HTTP requests, because ` wasi:http ` isn't in its imports. It can't access the clock, can't spawn threads, can't open sockets. The Wasm runtime enforces this at link time, if a component tries to import an interface that isn't in its world, instantiation fails before a single instruction executes."},{"heading":"Filesystem Sandboxing: Preopens and Directory Handles","level":2,"url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#filesystem-sandboxing-preopens-and-directory-handles","content":"WASI's filesystem model deserves special attention because it's where most plugin systems fail. In WASI Preview 2, a component doesn't get access to a filesystem, it gets handles to specific directories that the host \"preopens\":\n\n```rust\n// Host-side: configuring what the plugin can see\nuse wasmtime::component::*;\nuse wasmtime_wasi::{WasiCtxBuilder, DirPerms, FilePerms};\n\nlet mut wasi_ctx = WasiCtxBuilder::new();\n\n// Plugin gets read-only access to /data/input\nwasi_ctx.preopened_dir(\n \"/data/input\",\n \"input\",\n DirPerms::READ,\n FilePerms::READ,\n)?;\n\n// Plugin gets read-write access to /data/output\nwasi_ctx.preopened_dir(\n \"/data/output\",\n \"output\",\n DirPerms::all(),\n FilePerms::all(),\n)?;\n\n// That's it. No other filesystem access exists.\n```\n\nInside the plugin, the filesystem looks like this:\n\n```rust\n// Plugin-side: this is the entire visible filesystem\n// \"input/\" → maps to /data/input on the host (read-only)\n// \"output/\" → maps to /data/output on the host (read-write)\n\nfn transform(data: Vec<u8>) -> Result<Vec<u8>, String> {\n // This works, \"input\" is a preopened directory\n let config = std::fs::read_to_string(\"input/config.json\")\n .map_err(|e| e.to_string())?;\n\n // This works, \"output\" is preopened with write access\n std::fs::write(\"output/result.csv\", &processed_data)\n .map_err(|e| e.to_string())?;\n\n // This would fail at runtime, no preopen for \"/\"\n // std::fs::read_to_string(\"/etc/passwd\") // ERROR: no such preopen\n\n // This would fail, no preopen for \"..\"\n // std::fs::read_to_string(\"../../../etc/shadow\") // ERROR: path escapes preopen\n\n Ok(processed_data)\n}\n```\n\nPath traversal attacks (` ../../../etc/passwd `) are structurally impossible because the runtime resolves paths relative to preopens and rejects anything that escapes. This isn't a string filter on paths, it's enforced at the directory handle level."},{"heading":"Network Capabilities: wasi:http","level":2,"url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#network-capabilities-wasihttp","content":"For plugins that need to make outbound HTTP requests (fetching exchange rates, calling APIs, etc.), WASI Preview 2 provides ` wasi:http/outgoing-handler `. The critical design decision is that the host controls what the plugin can connect to:\n\n```rust\n// Host-side: creating an HTTP capability with domain allowlist\nuse wasmtime_wasi_http::WasiHttpCtx;\n\nstruct PluginHttpHandler {\n allowed_domains: HashSet<String>,\n}\n\nimpl PluginHttpHandler {\n fn handle_request(&self, request: OutgoingRequest) -> Result<IncomingResponse> {\n let authority = request.authority()\n .ok_or_else(|| anyhow!(\"missing authority\"))?;\n\n // Enforce domain allowlist\n if !self.allowed_domains.contains(&authority) {\n return Err(anyhow!(\n \"plugin attempted to connect to unauthorized domain: {}\",\n authority\n ));\n }\n\n // Forward the request to the actual HTTP client\n self.inner_client.send(request)\n }\n}\n\n// Plugin only gets access to specific domains\nlet http_handler = PluginHttpHandler {\n allowed_domains: HashSet::from([\n \"api.exchangerate.host\".into(),\n \"data.provider.com\".into(),\n ]),\n};\n```\n\nFrom the plugin's perspective, it just makes HTTP requests. It doesn't know about the allowlist, a request to an unauthorized domain simply fails:\n\n```rust\n// Plugin-side: making HTTP requests\nuse wasi::http::outgoing_handler;\n\nfn fetch_exchange_rate(currency: &str) -> Result<f64, String> {\n let request = OutgoingRequest::new(\n Method::Get,\n Some(\"api.exchangerate.host\"),\n Some(&format!(\"/latest?base={}\", currency)),\n Some(Scheme::Https),\n );\n\n let response = outgoing_handler::handle(request, None)\n .map_err(|e| format!(\"HTTP request failed: {:?}\", e))?;\n\n // Parse response...\n Ok(rate)\n}\n```"},{"heading":"Building a Secure Plugin Runtime","level":2,"url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#building-a-secure-plugin-runtime","content":"The following architecture demonstrates a production-grade approach. The key insight is that each plugin invocation gets a fresh component instance with a tailored capability set:\n\n```rust\nuse wasmtime::*;\nuse wasmtime::component::*;\nuse wasmtime_wasi::*;\n\nstruct PluginRuntime {\n engine: Engine,\n linker: Linker<PluginState>,\n plugin_components: HashMap<String, Component>,\n}\n\nimpl PluginRuntime {\n fn execute_plugin(\n &self,\n plugin_id: &str,\n tenant_id: &str,\n input: &[u8],\n ) -> Result<Vec<u8>> {\n let component = self.plugin_components.get(plugin_id)\n .ok_or_else(|| anyhow!(\"unknown plugin\"))?;\n\n // Each invocation gets isolated state\n let mut store = Store::new(&self.engine, PluginState {\n wasi: self.build_wasi_ctx(tenant_id)?,\n fuel_remaining: 1_000_000_000, // CPU budget\n memory_limit: 256 * 1024 * 1024, // 256MB\n });\n\n // Fuel metering prevents infinite loops\n store.set_fuel(1_000_000_000)?;\n\n let instance = self.linker.instantiate(&mut store, component)?;\n\n // Call the exported transform function\n let transform = instance.get_typed_func::<(Vec<u8>,), (Result<Vec<u8>, String>,)>(\n &mut store, \"transform\"\n )?;\n\n let (result,) = transform.call(&mut store, (input.to_vec(),))?;\n result.map_err(|e| anyhow!(\"plugin error: {}\", e))\n }\n\n fn build_wasi_ctx(&self, tenant_id: &str) -> Result<WasiCtx> {\n let mut builder = WasiCtxBuilder::new();\n\n // Tenant-isolated filesystem\n builder.preopened_dir(\n &format!(\"/data/tenants/{}/input\", tenant_id),\n \"input\",\n DirPerms::READ,\n FilePerms::READ,\n )?;\n\n builder.preopened_dir(\n &format!(\"/data/tenants/{}/output\", tenant_id),\n \"output\",\n DirPerms::all(),\n FilePerms::all(),\n )?;\n\n // No network, no clock, no env vars, no args\n // stdout/stderr captured for logging\n builder.stdout(LogCapture::new(tenant_id));\n builder.stderr(LogCapture::new(tenant_id));\n\n Ok(builder.build())\n }\n}\n```"},{"heading":"Component Composition: Virtualizing Capabilities","level":2,"url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#component-composition-virtualizing-capabilities","content":"One of Preview 2's most powerful features is component composition. You can interpose a component between the plugin and the host to transform or restrict capabilities without modifying either:\n\n```wit\n// A logging virtualization component that redacts sensitive data\nworld logging-filter {\n import wasi:logging/logging@0.1.0;\n export wasi:logging/logging@0.1.0;\n}\n```\n\n```rust\n// The filter component intercepts all log calls\nimpl wasi::logging::Logging for LogFilter {\n fn log(level: Level, context: &str, message: &str) {\n // Redact anything that looks like a credit card number\n let redacted = CREDIT_CARD_REGEX.replace_all(message, \"[REDACTED]\");\n // Forward to the real logging implementation\n wasi::logging::log(level, context, &redacted);\n }\n}\n```\n\nYou compose these at link time:\n\n```bash\n# Compose plugin with logging filter\nwasm-tools compose plugin.wasm \\\n --definitions logging-filter.wasm \\\n -o composed-plugin.wasm\n```\n\nThe resulting component has identical behavior from the plugin's perspective, but all logging goes through the filter. The plugin can't bypass it because the composition is enforced structurally."},{"heading":"Performance Reality Check","level":2,"url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#performance-reality-check","content":"Overhead is a common concern. Here are real numbers from a production system:\n\n```\nPlugin instantiation: ~50μs (component model, Wasmtime 28)\nSimple transform (1KB input): ~120μs total execution\nComplex transform (1MB input): ~8ms total execution\nMemory overhead per instance: ~2MB baseline + plugin allocation\n\nFor comparison:\nDocker container cold start: ~200ms-2s\nV8 isolate creation: ~5ms\nLua state creation: ~100μs (but no memory safety)\n```\n\nThe 50-microsecond instantiation means a fresh, fully-isolated plugin instance can be created per request without batching. That eliminates an entire class of state-leakage bugs between requests."},{"heading":"Lessons From Production Deployments","level":2,"url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#lessons-from-production-deployments","content":"Several lessons emerge from extended production usage:\n\n**Fuel metering is essential.** Without it, a plugin with an accidental infinite loop blocks a worker thread forever. Setting fuel limits based on the expected complexity of each plugin type and killing instances that exhaust their budget is critical.\n\n**Log capture prevents information leakage.** Plugins write to stdout/stderr thinking they're debugging, but in a multi-tenant system, those logs need to go to the right tenant's log stream. Capturing stdout via the WASI context handles this cleanly.\n\n**Pre-compile components.** Wasmtime supports ahead-of-time compilation with ` wasmtime compile `. A pre-compiled component loads in ~5μs instead of ~50μs. For hot-path plugins, this matters.\n\n```bash\n# Pre-compile for production deployment\nwasmtime compile --target x86_64-unknown-linux plugin.wasm -o plugin.cwasm\n```\n\n**Version WIT interfaces.** This lesson becomes apparent when a WIT interface update breaks plugins that were compiled against the old version. The component model's version matching prevents silent breakage, but you still need to maintain backward-compatible interfaces.\n\nThe capability-based security model in WASI Preview 2 is not merely a theoretical property, it is a practical engineering tool that eliminates entire categories of security bugs. When a plugin system literally cannot access resources it was not granted, the security posture improves dramatically. Compared to building sandboxes from cgroups and seccomp filters alone, the reduction in attack surface is substantial."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#questions","content":""},{"heading":"How is WASI Preview 2 different from WASI Preview 1?","level":3,"url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#question-1","content":"Preview 1 used a POSIX-like API with file descriptors. Preview 2 replaces this with the Component Model, where capabilities are expressed as typed interfaces (WIT). Instead of file descriptor numbers, you get strongly-typed handles to specific resources. It's also composable, you can virtualize any capability by interposing a component."},{"heading":"Can a WASI component escape its sandbox?","level":3,"url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#question-2","content":"Not through the WASI API surface. A component can only access capabilities that were explicitly granted by the host at instantiation time. There's no equivalent of opening /etc/passwd because the filesystem capability literally doesn't include that path. The only attack surface is the Wasm runtime itself (memory safety bugs in Wasmtime, etc.)."},{"heading":"Is WASI Preview 2 production-ready?","level":3,"url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#question-3","content":"As of early 2026, Wasmtime, WAMR, and Jco all support WASI Preview 2 with the component model. Cloudflare Workers uses a subset. It's production-ready for plugin systems and edge compute. For general-purpose server workloads, the ecosystem is still maturing, some interfaces like wasi:sockets are stabilizing."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#article-tips","content":"1. **Tip:** Never grant ` wasi:filesystem/preopens ` with write access to directories your plugin doesn't absolutely need. The whole point of capabilities is deny-by-default, if you grant everything because it's easier during development, you've built a sandbox with no walls.\n2. **Tip:** Use ` wasi:http/outgoing-handler ` instead of raw socket capabilities. It lets you enforce allowlists of target domains at the host level, so a compromised plugin can't phone home to ` evil-c2-server.ru ` even if it tries.\n3. **Tip:** When testing WASI components, use ` wasmtime serve --addr 127.0.0.1:8080 ` with the ` -S common ` flag to get sensible default capabilities. But in production, always define an explicit capability set, implicit defaults are a security liability.\n4. **Tip:** Pin your WIT interface versions. A plugin compiled against ` wasi:http@0.2.1 ` won't accidentally gain capabilities from ` wasi:http@0.2.3 ` because the component model enforces exact version matching at link time.\n\n- wasi\n- webassembly\n- capability-based-security\n- wasm\n- sandboxing\n- plugin-systems\n- wasi-http\n- component-model\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/capability-based-security-wasi-preview2"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/capability-based-security-wasi-preview2#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/capability-based-security-wasi-preview2"},"headline":"Capability-Based Security with WASI Preview 2: Least-Privilege Plugin Systems at the Runtime Level","description":"How WASI Preview 2's capability-based model enforces least-privilege at the runtime level. Filesystem sandboxing, network capabilities, wasi-http, and production-grade secure plugin architectures.","datePublished":"2025-11-25","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/capability-based-security-wasi-preview2","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Security","keywords":"wasi, webassembly, capability-based-security, wasm, sandboxing, plugin-systems, wasi-http, component-model","wordCount":1316,"timeRequired":"PT7M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/capability-based-security-wasi-preview2"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Capability-Based Security with WASI Preview 2: Least-Privilege Plugin Systems at the Runtime Level","item":"https://lucioduran.com/blog/capability-based-security-wasi-preview2"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"How is WASI Preview 2 different from WASI Preview 1?","acceptedAnswer":{"@type":"Answer","text":"Preview 1 used a POSIX-like API with file descriptors. Preview 2 replaces this with the Component Model, where capabilities are expressed as typed interfaces (WIT). Instead of file descriptor numbers, you get strongly-typed handles to specific resources. It's also composable, you can virtualize any capability by interposing a component."}},{"@type":"Question","name":"Can a WASI component escape its sandbox?","acceptedAnswer":{"@type":"Answer","text":"Not through the WASI API surface. A component can only access capabilities that were explicitly granted by the host at instantiation time. There's no equivalent of opening /etc/passwd because the filesystem capability literally doesn't include that path. The only attack surface is the Wasm runtime itself (memory safety bugs in Wasmtime, etc.)."}},{"@type":"Question","name":"Is WASI Preview 2 production-ready?","acceptedAnswer":{"@type":"Answer","text":"As of early 2026, Wasmtime, WAMR, and Jco all support WASI Preview 2 with the component model. Cloudflare Workers uses a subset. It's production-ready for plugin systems and edge compute. For general-purpose server workloads, the ecosystem is still maturing, some interfaces like wasi:sockets are stabilizing."}}]}]}],"contentHash":"94a7da53dc6b13776e58825a2769c136f23af0ad835afc2e30f794c5dad449ff","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/capability-based-security-wasi-preview2","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":64146,"contentBytes":13314}}
