Envoy can load WebAssembly modules that implement the Proxy-Wasm ABI. A Rust module can inspect or change HTTP headers, observe bodies, emit metrics, make asynchronous host calls, or stop a request with a local response.
It is tempting to call this a user-defined function. The more accurate model is an event-driven HTTP filter. Envoy owns the connection and executes the Wasm guest inline in a worker thread. The guest receives lifecycle callbacks and invokes host functions to interact with Envoy.
That execution model determines what is safe and what performs well.
Components and trust boundaries
A deployment contains at least four versioned pieces:
- Envoy and its Wasm extension;
- a Wasm runtime compiled into the Envoy build, such as V8;
- the Proxy-Wasm ABI implemented by host and guest;
- a language SDK—in this case
proxy-wasmfor Rust.
The commonly implemented ABI is Proxy-Wasm 0.2.1. Compatibility is not guaranteed by “it is a .wasm file.” Pin the Envoy image, runtime, SDK revision, target, and integration tests together.
Wasm isolates guest linear memory from the host process, but it does not make filter logic harmless. A filter can delay traffic, create high-cardinality metrics, leak headers into logs, or consume worker CPU. Treat it as production proxy code.
Root contexts and stream contexts
Proxy-Wasm distinguishes long-lived plugin state from request state:
- a root context receives VM/plugin configuration, periodic ticks, and can create stream contexts;
- an HTTP context is associated with one HTTP stream and receives request/response callbacks.
Envoy worker threads operate independently; Wasm instances and their memory are not a general shared-memory mechanism across workers. Cross-request or cross-worker state should use host facilities designed for that purpose, with clear consistency limits.
A small header filter
The following filter rejects requests without x-tenant-id and forwards accepted requests with a normalized marker header.
use log::info;
use proxy_wasm::traits::{Context, HttpContext};
use proxy_wasm::types::{Action, LogLevel};
proxy_wasm::main! {{
proxy_wasm::set_log_level(LogLevel::Info);
proxy_wasm::set_http_context(|_, _| -> Box<dyn HttpContext> {
Box::new(TenantFilter)
});
}}
struct TenantFilter;
impl Context for TenantFilter {}
impl HttpContext for TenantFilter {
fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action {
let tenant = self.get_http_request_header("x-tenant-id");
match tenant.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
Some(value) => {
self.set_http_request_header("x-tenant-id", Some(value));
self.set_http_request_header("x-filtered-by", Some("tenant-filter"));
info!("accepted request with tenant header");
Action::Continue
}
None => {
self.send_http_response(
400,
vec![("content-type", "text/plain")],
Some(b"missing x-tenant-id\n"),
);
Action::Pause
}
}
}
}
The SDK macro exports the ABI entry points and registers the context factory. The callback executes on Envoy’s request path. It must be deterministic, bounded, and free of blocking work.
This example demonstrates request shape validation, not authentication. A client can invent x-tenant-id; identity must come from a trusted mechanism such as validated credentials or authenticated metadata, and the proxy should remove spoofable upstream headers before writing trusted ones.
Build the guest for the SDK’s supported target
A minimal crate is a cdylib:
[lib]
crate-type = ["cdylib"]
[dependencies]
log = "0.4"
proxy-wasm = "0.2"
For the classic Rust Proxy-Wasm SDK, examples are commonly compiled for wasm32-unknown-unknown:
rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown
Do not substitute a WASI target merely because it is newer. Proxy-Wasm and WASI are different host interfaces. Use the target documented by the pinned SDK and validate the resulting module with the exact Envoy image.
The production build should also record the source revision and artifact digest. Wasm optimization or stripping can reduce size, but run compatibility tests after post-processing the module.
Configure the Envoy HTTP filter
A local module can be placed before the router in an HTTP filter chain:
http_filters:
- name: envoy.filters.http.wasm
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.wasm.v3.Wasm
config:
name: tenant_filter
root_id: tenant_filter
vm_config:
vm_id: tenant_filter_vm
runtime: envoy.wasm.runtime.v8
code:
local:
filename: /etc/envoy/tenant_filter.wasm
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
Filter order is behavior. A filter placed before authentication sees untrusted request state; a filter placed after decompression may see larger bodies; a local response can prevent later filters and the upstream cluster from running.
The Wasm HTTP filter has historically been documented as experimental. Verify configuration names against the deployed Envoy version rather than copying a “latest” example into an older image.
Configuration belongs in the root context
A real filter often needs policy supplied through Envoy configuration. The root context receives plugin configuration bytes and can validate them before accepting configuration. Invalid policy should fail closed or prevent the plugin from starting according to the intended rollout strategy; silently applying partial defaults is dangerous.
Keep configuration parsing separate from request evaluation:
configuration callback:
bytes -> parse -> validate ranges/names -> immutable policy
request callback:
trusted policy + request metadata -> bounded decision
Version configuration explicitly. A rollout may briefly run multiple proxy versions and module versions, so forward/backward behavior must be deliberate.
Asynchronous host calls pause and resume
The guest must not block a worker thread while waiting for a remote authorization service. Proxy-Wasm provides a host call that asks Envoy to dispatch an HTTP request. The stream callback returns Action::Pause; later, a response callback receives the result and calls resume_http_request() or sends a local response.
This creates a state machine:
request headers
-> validate local fields
-> dispatch callout
-> PAUSED
callout completion
-> validate status/body/size
-> CONTINUE or local response
timeout/failure
-> explicit fail-open or fail-closed policy
Store only the minimal per-stream state needed to interpret the callback. Bound response body reads, set a deadline, and handle callback arrival after the downstream stream has ended.
Avoid making a remote call for every request when a local verification method or bounded cache can provide the same security property. A proxy extension magnifies downstream latency and failure modes.
Body processing requires a buffering policy
Request and response bodies arrive in chunks. A callback’s body_size and end-of-stream indicator must be interpreted according to the ABI and host. If a filter needs a complete body, it may pause and buffer—but unbounded buffering turns large or slow requests into memory pressure.
Define:
- maximum inspected bytes;
- behavior for compressed bodies;
- streaming versus complete-document parsing;
- content types accepted;
- response on truncation or parse failure;
- whether changing a body requires updating content length or removing it.
Header-only filters are easier to reason about and should remain header-only unless body access is essential.
Observability without self-inflicted outages
Useful signals include:
- requests allowed and denied by reason;
- callout latency, timeout, and failure rate;
- VM/plugin initialization failures;
- callback CPU time and trapped executions;
- paused streams and buffered bytes;
- module version and policy version.
Do not log credentials, cookies, authorization headers, or full request bodies. Avoid tenant or request IDs as metric label values if their cardinality is unbounded. Use traces or sampled structured logs for high-cardinality diagnostics.
Test at three levels
- Pure Rust tests: extract policy evaluation into ordinary functions and test edge cases without a Wasm host.
- ABI/guest tests: exercise callbacks and host-call expectations with the SDK’s examples or test harness where available.
- Envoy integration tests: start the exact Envoy image, load the compiled module, send requests through the listener, and assert upstream and local responses.
Integration tests should cover malformed configuration, missing headers, duplicate headers, large bodies, callout timeout, upstream disconnect, VM trap, and module load failure. A test that only compiles the Rust crate does not prove ABI compatibility.
Deployment discipline
- Pin module content by digest when fetching remotely.
- Roll out to a small proxy set first.
- Define fail-open/fail-closed behavior before an incident.
- Apply CPU, memory, and request-size limits.
- Keep a fast rollback path to the previous module/configuration.
- Treat an ABI, SDK, runtime, or Envoy upgrade as a compatibility change.
- Benchmark tail latency with the filter both enabled and disabled.
Proxy-Wasm is valuable because it puts portable logic close to traffic while preserving a host/guest boundary. The trade-off is that a tiny Rust module participates in every request. Its state machine, compatibility matrix, and failure policy deserve the same rigor as the proxy configuration around it.