WASM Component Model Security Boundaries: Composition, Capability Passing, and Trust Decisions
Problem
The component model turns WebAssembly into a composition primitive. A component is a self-contained module with a typed interface declared in WIT (WebAssembly Interface Type) — exports it provides, imports it requires, and resource types it manipulates. Components compose: one component’s exports satisfy another component’s imports, producing a new composite component with reduced (or zero) unmet imports.
The security implications are different from monolithic WASM modules:
- Each composition wire is a capability decision. When component A’s
wasi:filesystem/types/descriptoris wired to component B’s import, B receives a filesystem handle from A. Whoever controls the composition decides what each component can access. - Components can sub-delegate capabilities they hold. If A holds
wasi:http/outgoing-handlerand exposes a function that takes a URL and returns the response, A is voluntarily exposing some of its HTTP capability to its callers. The shape of that exposure is the security boundary. - The WIT is the audit document. Every cross-component call is statically typed and named. Auditing a composed application means reading the WIT files, not the bytecode.
- Resource types have lifetimes. Resources (file descriptors, sockets, custom typed handles) are passed as
borrow<T>(read-only, scoped) orown<T>(transferred, dropped at end of lifetime). Misuse leaks state across components.
By 2026, real applications compose multiple components: a platform component holding capabilities, a tenant component running customer logic, a logging component sinking observability events, a configuration component reading per-deployment settings. The composition graph defines the security model.
The specific gaps in a 2026 component-model deployment:
- Composition done at deploy time without explicit capability review.
- Resource handles passed without lifetime annotations, leaking across components.
- Custom interfaces designed without considering the trust boundary they create.
- Sub-delegation patterns that grant more capability than intended (passing a filesystem descriptor to a component that should only have access to one file).
- Lack of WIT-level audit tooling integrated into the deploy pipeline.
This article covers WIT trust-boundary design, capability containment patterns, resource lifetime hygiene, audit tooling, and runtime detection of capability misuse.
Target systems: Component model implementations: Wasmtime 22+ (Rust + C API), JCO (JavaScript), wasm-tools, cargo-component. WIT version: 0.2.0 (component-model-1.0).
Threat Model
- Adversary 1 — Sub-component author: writes a component that, when composed into a larger application, abuses the capabilities passed to it.
- Adversary 2 — Composer mis-wires: a deploy-time configuration error wires a high-trust capability to a low-trust component (e.g., the platform’s full filesystem handle passed to an untrusted tenant component).
- Adversary 3 — Resource handle leaks across components: a component receives a
borrow<descriptor>and stores it in shared state where another, less-trusted component retrieves it. - Adversary 4 — Interface design that exposes unsafe operations: a component’s
exportinterface offers a function whose semantics expose more than the designer intended (e.g.,fs.read(path)that takes any path). - Access level: Adversary 1 has component-build access. Adversary 2 has deploy-time configuration access. Adversary 3 has source-code access to the component design. Adversary 4 has interface-design authority.
- Objective: Acquire capabilities through composition that are not justified by the component’s role.
- Blast radius: Bounded by the host’s enumeration of capabilities at the outermost component. A correctly-designed composition propagates only the capabilities each level needs; a mis-designed composition gives leaf components the same view as the root.
Configuration
Step 1: Design Components With Trust Tiers in Mind
A composition graph has implicit trust levels. Make them explicit in the WIT.
// platform.wit — highest trust, holds all capabilities.
package myorg:platform@1.0.0;
world platform {
// Imports: the full WASI surface.
import wasi:filesystem/types@0.2.0;
import wasi:http/outgoing-handler@0.2.0;
import wasi:io/streams@0.2.0;
// Exports: narrow interfaces for sub-components.
export myorg:storage/buckets@1.0.0;
export myorg:network/api-call@1.0.0;
export myorg:logging/sink@1.0.0;
}
// tenant.wit — untrusted, holds only what platform exposes.
package myorg:tenant@1.0.0;
world tenant {
import myorg:storage/buckets@1.0.0;
import myorg:network/api-call@1.0.0;
import myorg:logging/sink@1.0.0;
export wasi:http/incoming-handler@0.2.0;
}
The tenant component cannot import wasi:filesystem directly. Even if a developer modifies the source to add the import, cargo component build will fail to link unless the tenant world is amended — and amending the world is a reviewable event.
Step 2: Capability-Constrained Interface Design
Every export interface is a capability the component is willing to share. Design narrowly.
Wrong:
interface storage {
// Exposes raw filesystem semantics. Caller can read any file the
// platform has access to.
read-file: func(path: string) -> result<list<u8>, error>;
write-file: func(path: string, data: list<u8>) -> result<unit, error>;
}
Right:
interface storage {
// Exposes scoped storage. Bucket names map to platform-managed paths.
// Caller cannot escape via "../" — the platform implementation rejects.
resource bucket {
get: func(key: string) -> result<list<u8>, error>;
put: func(key: string, value: list<u8>) -> result<unit, error>;
delete: func(key: string) -> result<unit, error>;
list-keys: func(prefix: string) -> result<list<string>, error>;
}
variant error {
not-found,
unauthorized,
quota-exceeded,
invalid-key,
}
open-bucket: func(name: string) -> result<bucket, error>;
}
The bucket resource is the capability. A tenant holds bucket handles only for buckets the platform explicitly opened on its behalf. The platform’s open-bucket implementation maps bucket names to controlled storage paths, validates the tenant’s authorization, and returns a bucket handle whose operations cannot escape the bucket.
Step 3: Resource Lifetime Hygiene
Resources have lifetimes. The component model expresses ownership and borrowing:
interface storage {
resource bucket {
// borrow<bucket>: caller retains ownership; bucket is read but not consumed.
snapshot: func(b: borrow<bucket>) -> result<snapshot, error>;
// own<bucket>: caller transfers ownership; bucket is consumed.
finalize: func(b: own<bucket>) -> result<final-state, error>;
}
}
In Rust, borrow becomes &Bucket, own becomes Bucket (moved). Accidental cloning or storing a borrowed handle does not compile. In other languages (JS, Python), bindings enforce lifetime via runtime drops.
A pattern to avoid:
// Wrong — storing a borrow indefinitely.
struct Cache {
bucket: Option<storage::Bucket>, // own<bucket>; takes ownership
}
impl Cache {
fn save(&mut self, b: storage::Bucket) {
self.bucket = Some(b); // bucket retained beyond intended scope
}
}
The handle persists in self.bucket for the lifetime of the Cache instance. If the platform expected the handle to be dropped after a single operation, this pattern leaks the capability.
Better:
fn use_bucket_briefly(b: storage::Bucket) -> Result<(), Error> {
// bucket dropped at end of function; capability returns to platform.
b.put("key", b"value")?;
Ok(())
}
Step 4: Composition With wasm-tools
Compose components at deploy time, not at runtime. Composition is a static operation that produces a new component artifact:
# Compose tenant into the platform's exported world.
wasm-tools compose \
--definitions platform.wasm \
--output composed.wasm \
tenant.wasm
# Inspect the composed artifact.
wasm-tools component wit composed.wasm > composed-world.wit
diff composed-world.wit expected-world.wit
Validate that the composed artifact’s imports match what the host plans to provide:
# Audit the imports of the composed artifact.
wasm-tools component wit composed.wasm | grep "^ import"
# import wasi:http/incoming-handler@0.2.0
# (no other imports — platform's WASI imports are satisfied internally)
If the composed artifact unexpectedly imports wasi:filesystem/types, something has bypassed the platform layer. Reject and investigate.
Step 5: Audit Tooling in the Pipeline
Component-model audits are static and cheap. Run them on every PR.
# .github/workflows/component-audit.yml
name: Component audit
on: [pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
- run: cargo install --locked wasm-tools
- name: Build component
run: cargo component build --release
- name: Audit imports against allowlist
run: |
ALLOWED=(
"wasi:io/streams@0.2.0"
"wasi:io/error@0.2.0"
"wasi:clocks/monotonic-clock@0.2.0"
"wasi:http/incoming-handler@0.2.0"
"wasi:http/outgoing-handler@0.2.0"
"myorg:platform/storage@1.0.0"
"myorg:platform/api-call@1.0.0"
)
IMPORTS=$(wasm-tools component wit \
target/wasm32-wasip1/release/payments.wasm \
| awk '/^ import / {print $2}' | tr -d ';')
for i in $IMPORTS; do
if ! printf '%s\n' "${ALLOWED[@]}" | grep -qx "$i"; then
echo "Forbidden import: $i"
exit 1
fi
done
echo "All imports approved."
Block the merge if a new import not on the allowlist appears. Adding a new import is a security-review event, not a routine code change.
Step 6: Runtime Capability Logging
When the host hands out a capability handle, log it. This produces an audit trail of which components held which capabilities at which times.
// In the host harness:
struct AuditedFilesystemHandle {
inner: wasi_filesystem::Descriptor,
component_id: String,
granted_at: std::time::Instant,
}
impl AuditedFilesystemHandle {
fn new(inner: wasi_filesystem::Descriptor, component_id: &str) -> Self {
tracing::info!(
component_id = %component_id,
event = "capability_granted",
capability = "wasi:filesystem/types/descriptor",
"filesystem capability granted to component"
);
Self {
inner,
component_id: component_id.into(),
granted_at: std::time::Instant::now(),
}
}
}
impl Drop for AuditedFilesystemHandle {
fn drop(&mut self) {
let lifetime = self.granted_at.elapsed();
tracing::info!(
component_id = %self.component_id,
event = "capability_dropped",
lifetime_ms = lifetime.as_millis() as u64,
);
}
}
Build dashboards on capability lifetime distribution. A component holding a filesystem descriptor for orders of magnitude longer than its peers is a leak signal.
Expected Behaviour
| Signal | Without Component-Model Discipline | With |
|---|---|---|
| Inspecting a composed app’s permissions | Read the source code | wasm-tools component wit shows full surface |
| Adding a new host call from a component | Source change; hard to audit | WIT change; CI audit fails until allowlist is updated |
| Sub-component’s capability | Inherits everything passed to its parent | Receives only the explicit capabilities passed via WIT interfaces |
| Resource handle stored indefinitely | Compiles, runs, leaks | Lifetime annotations enforce drop |
| Cross-component data flow | Implicit, often via shared globals | Explicit via interface calls; enumerable from WIT |
| Audit complexity | Read all source | Read WIT files; bytecode is sealed contract |
Verify a composed application has the expected surface:
wasm-tools component wit ./composed.wasm | head -30
# Expected: only the imports the host has agreed to provide.
# Negative test: a tampered tenant that adds wasi:filesystem to its world.
# Composition should fail or the audit step should catch the new import.
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Narrow interface design | Capabilities cannot accidentally widen | More design work; more types defined | Reuse standard interfaces (wasi:keyvalue, wasi:http) where possible; only invent custom interfaces for application-specific operations. |
| Static composition | Auditable; deterministic | Requires deploy-time tooling; runtime composition not supported | Make composition part of the build, not the runtime. |
| Resource lifetime annotations | Compile-time enforcement of handle lifetime | Some complexity in interface design | Idiomatic in Rust; bindings for other languages (JS, Python, Go) handle this transparently. |
| WIT-level audits in CI | Catches capability creep | Maintenance of allowlists | Keep the allowlist with the application source; review with the same gravity as IAM changes. |
| Runtime capability logging | Audit trail for forensics | Telemetry overhead, log volume | Sample at high rate for ephemeral capabilities; record full lifecycle for long-lived. |
| Trust-tier separation | Different components can have different security postures | Composition graphs grow complex | Document the trust-tier diagram alongside the WIT files; keep it in source control. |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| Composition adds an unexpected import | Composed artifact requires a host-provided capability you did not anticipate | wasm-tools component wit of the composed artifact shows the unexpected import |
Investigate which sub-component introduced it. Reject the deploy until the change is reviewed. |
| Sub-component leaks a borrowed resource | Capability handle persists in a way that lets unrelated code use it | Capability lifetime metric outlier; sub-component holds a handle past expected lifetime | Audit the sub-component’s source; refactor to drop handles within the request scope. |
| Wide interface accidentally exposes platform capability | A custom interface that takes raw paths or URLs lets the caller request anything | Code review or security audit reveals the wide signature | Refactor to use scoped resource handles. The change is an ABI break for the interface; coordinate with consumers. |
| Composition tool version skew | Components built against wasm-tools v1.220 behave differently when composed by v1.230 |
Composition errors at deploy time | Pin tooling versions; treat upgrades as version-controlled changes to the build system. |
Component imports wasi:filesystem despite policy |
A component the developer claimed had no filesystem access actually does | CI audit step flags the import | Block merge. Investigate whether the dependency tree pulled in a transitive component that introduced the import. |
| Resource ABI mismatch across versions | Components built against wasi:io/streams@0.2.0 linked against 0.2.1 runtime fail |
Runtime instantiation errors with type-checking failures | Pin runtime and component versions in deploy manifest. Roll versions in coordinated waves. |