WebAssembly at the Edge: Implementing Zero Trust Authorization in WASM Filters
The Authorization Round-Trip Problem
Zero trust architecture demands that every request be authenticated and authorized at the point of enforcement, not assumed trusted because it arrived on an internal network. The conventional implementation routes each request through a Policy Decision Point (PDP): the proxy or sidecar makes an HTTP call to an OPA instance or an external authorization service, waits for a permit/deny decision, then forwards or rejects the request.
That round-trip costs 1–5 ms under normal conditions. Under load or partial failure, it becomes the latency and availability bottleneck for every service in your mesh. More critically, a PDP that is slow, partially unavailable, or misconfigured in fail_open mode becomes the fastest path to authorization bypass. You have created a central choke point for a property — authorization — that should be ubiquitous and in-path.
WASM filters eliminate the external call entirely. The authorization logic — JWT signature verification, claim validation, OPA policy evaluation, SPIFFE SVID checking — runs inside the proxy or edge runtime, inline with the request. No network call, no external dependency, no fail-open risk from a degraded sidecar. The filter either permits or denies the request before the upstream ever sees it.
This is not free. Distributing authorization logic into filter instances creates a policy distribution problem: how do you update policy across hundreds of filter instances without a deployment event? And WASM’s execution model imposes hard constraints — no synchronous external calls from within a filter’s hot path. Understanding these tradeoffs determines whether in-filter authorization is the right architecture for a given control.
Envoy WASM Filter API
Envoy’s HTTP filter chain processes requests through a sequence of filter stages. The WASM HTTP filter (envoy.filters.http.wasm) loads a .wasm binary and gives it access to request and response lifecycle hooks via the proxy-wasm ABI. This ABI — standardized at proxy-wasm/spec — defines the set of host functions a WASM module can call into Envoy and the callbacks Envoy invokes on the module.
The lifecycle hooks relevant to authorization:
on_http_request_headers— called after request headers are received, before the body. The canonical point for JWT and SPIFFE validation. The filter can inspect all headers, add or modify them, and either callcontinue_request()to forward orsend_local_response()to reject.on_http_request_body— called with chunks of the request body. Available if you need to validate body content, but the body is streamed and buffering has memory implications.on_http_response_headers— called before response headers are forwarded to the client. Used less often for authorization, but useful for stripping sensitive upstream headers.
The host functions a filter calls for authorization work:
get_http_request_header— retrieve a named request header value.get_property— retrieve Envoy connection metadata: peer certificate, peer SAN, connection ID, route configuration properties.send_local_response— terminate the request with a status code and body, bypassing the upstream.set_http_request_header— add or modify a header before forwarding (used to inject validated identity claims as headers for upstream consumption).dispatch_http_call— make an asynchronous outbound HTTP call. This is the only way a WASM filter can reach external services, and it is asynchronous — the current request is paused and resumed in a callback. It cannot be used as a blocking PDP call.
The critical constraint: there is no synchronous external call mechanism. Authorization logic that requires a live external service call cannot be done inline in the hot path. Everything must be evaluated from data already present in the filter: the request headers, the connection metadata, and pre-loaded state (policy bundles, JWKS cached in shared memory).
For a deeper look at the filter ABI, CVE patterns, and isolation model, see Envoy WASM Filters for API Security.
Implementing JWT Validation in a Rust WASM Filter
The proxy-wasm Rust SDK (proxy-wasm crate) provides the HttpContext trait with on_http_request_headers as the primary authorization hook. JWT validation requires parsing the Authorization header, base64-decoding the header and payload, verifying the signature against a cached JWKS, and checking standard claims.
[dependencies]
proxy-wasm = "0.2"
base64 = "0.21"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
p256 = { version = "0.13", features = ["ecdsa"] }
rsa = { version = "0.9", features = ["sha2"] }
sha2 = "0.10"
use proxy_wasm::traits::*;
use proxy_wasm::types::*;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use serde::Deserialize;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
proxy_wasm::main! {{
proxy_wasm::set_log_level(LogLevel::Info);
proxy_wasm::set_root_context(|_| -> Box<dyn RootContext> {
Box::new(AuthRootContext { jwks: None })
});
}}
struct AuthRootContext {
jwks: Option<Jwks>,
}
#[derive(Deserialize, Clone)]
struct Jwks {
keys: Vec<Jwk>,
}
#[derive(Deserialize, Clone)]
struct Jwk {
kid: String,
kty: String,
alg: String,
n: Option<String>,
e: Option<String>,
x: Option<String>,
y: Option<String>,
crv: Option<String>,
}
impl RootContext for AuthRootContext {
fn on_vm_start(&mut self, _vm_configuration_size: usize) -> bool {
self.set_tick_period(Duration::from_secs(300));
self.fetch_jwks();
true
}
fn on_tick(&mut self) {
self.fetch_jwks();
}
fn create_http_context(&self, _context_id: u32) -> Option<Box<dyn HttpContext>> {
Some(Box::new(AuthHttpContext {
jwks: self.jwks.clone(),
}))
}
fn get_type(&self) -> Option<ContextType> {
Some(ContextType::HttpContext)
}
}
impl AuthRootContext {
fn fetch_jwks(&mut self) {
let _ = self.dispatch_http_call(
"jwks-cluster",
vec![
(":method", "GET"),
(":path", "/.well-known/jwks.json"),
(":authority", "auth.internal"),
],
None,
vec![],
Duration::from_secs(5),
);
}
}
impl Context for AuthRootContext {
fn on_http_call_response(
&mut self,
_token_id: u32,
_num_headers: usize,
body_size: usize,
_num_trailers: usize,
) {
if let Some(body) = self.get_http_call_response_body(0, body_size) {
if let Ok(jwks) = serde_json::from_slice::<Jwks>(&body) {
self.jwks = Some(jwks);
}
}
}
}
struct AuthHttpContext {
jwks: Option<Jwks>,
}
impl Context for AuthHttpContext {}
impl HttpContext for AuthHttpContext {
fn on_http_request_headers(&mut self, _num_headers: usize, _end_of_stream: bool) -> Action {
let auth_header = match self.get_http_request_header("authorization") {
Some(h) => h,
None => return self.reject(401, "missing authorization header"),
};
let token = match auth_header.strip_prefix("Bearer ") {
Some(t) => t.to_string(),
None => return self.reject(401, "invalid authorization scheme"),
};
match self.validate_jwt(&token) {
Ok(claims) => {
self.set_http_request_header("x-verified-sub", Some(&claims.sub));
self.set_http_request_header("x-verified-scope", Some(&claims.scope.unwrap_or_default()));
Action::Continue
}
Err(e) => {
proxy_wasm::hostcalls::log(LogLevel::Warn, &format!("jwt validation failed: {}", e)).ok();
self.reject(401, "unauthorized")
}
}
}
}
#[derive(Deserialize)]
struct JwtHeader {
kid: Option<String>,
alg: String,
}
#[derive(Deserialize)]
struct JwtClaims {
sub: String,
exp: u64,
iss: String,
scope: Option<String>,
}
impl AuthHttpContext {
fn reject(&self, status: u32, body: &str) -> Action {
self.send_http_response(
status,
vec![("content-type", "application/json")],
Some(format!(r#"{{"error":"{}"}}"#, body).as_bytes()),
);
Action::Pause
}
fn validate_jwt(&self, token: &str) -> Result<JwtClaims, String> {
let parts: Vec<&str> = token.splitn(3, '.').collect();
if parts.len() != 3 {
return Err("malformed jwt".into());
}
let header_bytes = URL_SAFE_NO_PAD
.decode(parts[0])
.map_err(|_| "invalid header encoding")?;
let header: JwtHeader = serde_json::from_slice(&header_bytes)
.map_err(|_| "invalid header json")?;
let payload_bytes = URL_SAFE_NO_PAD
.decode(parts[1])
.map_err(|_| "invalid payload encoding")?;
let claims: JwtClaims = serde_json::from_slice(&payload_bytes)
.map_err(|_| "invalid payload json")?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| "clock error")?
.as_secs();
if claims.exp < now {
return Err(format!("token expired at {}", claims.exp));
}
if claims.iss != "https://auth.example.com" {
return Err(format!("unexpected issuer: {}", claims.iss));
}
let jwks = self.jwks.as_ref().ok_or("jwks not loaded")?;
let kid = header.kid.as_deref().unwrap_or("");
let jwk = jwks
.keys
.iter()
.find(|k| k.kid == kid)
.ok_or_else(|| format!("no key for kid {}", kid))?;
let signing_input = format!("{}.{}", parts[0], parts[1]);
let signature = URL_SAFE_NO_PAD
.decode(parts[2])
.map_err(|_| "invalid signature encoding")?;
self.verify_signature(&header.alg, jwk, signing_input.as_bytes(), &signature)?;
Ok(claims)
}
fn verify_signature(
&self,
alg: &str,
jwk: &Jwk,
message: &[u8],
signature: &[u8],
) -> Result<(), String> {
match alg {
"ES256" => self.verify_es256(jwk, message, signature),
"RS256" => self.verify_rs256(jwk, message, signature),
_ => Err(format!("unsupported algorithm: {}", alg)),
}
}
fn verify_es256(&self, jwk: &Jwk, message: &[u8], signature: &[u8]) -> Result<(), String> {
use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey};
use p256::EncodedPoint;
let x = URL_SAFE_NO_PAD
.decode(jwk.x.as_deref().ok_or("missing x")?)
.map_err(|_| "invalid x")?;
let y = URL_SAFE_NO_PAD
.decode(jwk.y.as_deref().ok_or("missing y")?)
.map_err(|_| "invalid y")?;
let point = EncodedPoint::from_affine_coordinates(
x.as_slice().into(),
y.as_slice().into(),
false,
);
let vk = VerifyingKey::from_encoded_point(&point)
.map_err(|_| "invalid ec point")?;
let sig = Signature::from_der(signature)
.or_else(|_| Signature::from_slice(signature))
.map_err(|_| "invalid signature format")?;
vk.verify(message, &sig).map_err(|_| "signature verification failed".into())
}
fn verify_rs256(&self, jwk: &Jwk, message: &[u8], signature: &[u8]) -> Result<(), String> {
use rsa::{pkcs1::DecodeRsaPublicKey, RsaPublicKey};
use rsa::signature::Verifier;
use rsa::pkcs1v15::{Signature, VerifyingKey};
use sha2::Sha256;
let n = URL_SAFE_NO_PAD
.decode(jwk.n.as_deref().ok_or("missing n")?)
.map_err(|_| "invalid n")?;
let e = URL_SAFE_NO_PAD
.decode(jwk.e.as_deref().ok_or("missing e")?)
.map_err(|_| "invalid e")?;
let pub_key = RsaPublicKey::new(
rsa::BigUint::from_bytes_be(&n),
rsa::BigUint::from_bytes_be(&e),
)
.map_err(|_| "invalid rsa key")?;
let vk: VerifyingKey<Sha256> = VerifyingKey::new(pub_key);
let sig = Signature::try_from(signature).map_err(|_| "invalid signature")?;
vk.verify(message, &sig).map_err(|_| "rsa signature verification failed".into())
}
}
A few points about this implementation that matter for security. The JWKS is fetched asynchronously via dispatch_http_call in the root context’s tick handler, not per request. The fetched keys live in the root context and are cloned into each HTTP context when Envoy creates it. This means there is a window during startup — before the first JWKS response arrives — where self.jwks is None and every request is rejected. That is deliberate fail-closed behavior. If you need to accept requests before JWKS loads, you must embed a bootstrap key in the filter configuration.
The verify_signature function explicitly rejects any algorithm not in its match arm. This prevents algorithm confusion attacks where an attacker changes the alg header to none or substitutes an HMAC algorithm using a public key as the HMAC secret. The allowed algorithms are ES256 and RS256 — both require the full asymmetric verification path.
OPA Policy Evaluation as an Embedded WASM Module
OPA’s opa build command compiles a Rego policy bundle to a .wasm file. That file can be embedded into a larger WASM module using a link step, or loaded as a data segment and executed via the OPA WASM ABI. The OPA WASM ABI exposes a single entry point: opa_eval, which takes a serialized input document and returns a serialized result document.
The compilation step:
opa build -t wasm -e authz/allow policies/authz.rego data.json
tar xzf bundle.tar.gz /policy.wasm
The resulting policy.wasm exposes opa_malloc, opa_free, opa_json_parse, opa_eval, and opa_json_dump as exported functions. Calling them from the host — or from within a parent WASM module — requires managing OPA’s internal heap via its own opa_malloc/opa_free, not the host allocator.
Within an Envoy WASM filter, you cannot load a second WASM module at runtime — Envoy’s WASM VM loads exactly one module per filter configuration. The integration approach is to link the OPA WASM module into your filter module at build time using wasm-merge (from the Binaryen toolchain) or by using OPA’s Rust binding crate, which wraps the OPA WASM ABI and compiles the policy into the same binary.
use opa_wasm::Runtime;
fn evaluate_policy(claims: &JwtClaims, resource: &str, action: &str) -> Result<bool, String> {
static POLICY_WASM: &[u8] = include_bytes!("../policy.wasm");
let runtime = Runtime::new(POLICY_WASM).map_err(|e| e.to_string())?;
let input = serde_json::json!({
"subject": claims.sub,
"scope": claims.scope,
"resource": resource,
"action": action,
});
let result: serde_json::Value = runtime
.evaluate("authz/allow", &input)
.map_err(|e| e.to_string())?;
result
.as_bool()
.ok_or_else(|| "unexpected policy result type".into())
}
The include_bytes! macro embeds the compiled OPA WASM bundle into the filter binary at build time. This is what makes the policy available without an external call — it is data in the filter’s own memory. The consequence is that updating policy requires rebuilding and redeploying the filter WASM binary. The policy distribution challenge this creates is addressed in a later section.
The CPU cost of opa_eval on a warm runtime is typically 50–200 μs depending on policy complexity, which is acceptable for the authorization path but not negligible. Complex Rego with large data documents — role hierarchies, resource ACLs — can push this into the millisecond range. Profile before committing to in-filter OPA evaluation for high-throughput paths.
SPIFFE SVID Verification in Envoy WASM
In a service mesh using SPIFFE and SPIRE, every workload presents a short-lived X.509 SVID in its mTLS client certificate. The SPIFFE ID is encoded as a URI SAN in the certificate: spiffe://trust-domain/path/to/workload. Verifying the caller’s SPIFFE identity in the filter — rather than trusting it from an upstream header — closes the injection path where a compromised workload fakes its identity by setting a header.
Envoy exposes the peer certificate via the get_property host function under the connection.peer_certificate path. The returned value is the DER-encoded X.509 certificate for the mTLS peer.
For the full SPIFFE/SPIRE setup that makes these SVIDs available, see SPIFFE and SPIRE for Workload Identity.
fn extract_spiffe_id(&self) -> Option<String> {
let cert_der = self.get_property(vec!["connection", "peer_certificate"])?;
let (_, cert) = x509_parser::parse_x509_certificate(&cert_der).ok()?;
for san in cert.subject_alternative_names()?.sans.iter() {
if let x509_parser::extensions::GeneralName::URI(uri) = san {
if uri.starts_with("spiffe://") {
return Some(uri.to_string());
}
}
}
None
}
fn validate_spiffe_id(&self, spiffe_id: &str, allowed_trust_domain: &str) -> Result<(), String> {
let prefix = format!("spiffe://{}/", allowed_trust_domain);
if !spiffe_id.starts_with(&prefix) {
return Err(format!(
"spiffe id {} not in trust domain {}",
spiffe_id, allowed_trust_domain
));
}
Ok(())
}
Called from on_http_request_headers:
match self.extract_spiffe_id() {
Some(id) => {
self.validate_spiffe_id(&id, "trust.example.com")?;
self.set_http_request_header("x-verified-spiffe-id", Some(&id));
}
None => {
return Err("no spiffe svid in peer certificate".into());
}
}
The critical dependency: connection.peer_certificate is only populated if Envoy has completed an mTLS handshake and the peer presented a certificate. If the listener is configured for optional mTLS or TLS without client verification, the property returns None and the filter cannot extract the SPIFFE ID. The filter must fail closed in that case — treating the absence of a SPIFFE ID as an authorization failure, not as a passthrough.
Cloudflare Workers: Edge-Native Zero Trust
Cloudflare Access adds a layer in front of any Cloudflare-proxied application. When a user authenticates through Access, Cloudflare injects a CF-Access-JWT-Assertion header containing a JWT signed by Cloudflare’s key pair for your Access team. This JWT is the authenticated identity assertion at the edge.
Validating this header in a Worker — rather than passing it through to the origin — means the origin never needs to implement authentication logic. Any request that reaches the origin has already had its Cloudflare Access JWT verified at the edge.
const CERTS_URL = "https://your-team.cloudflareaccess.com/cdn-cgi/access/certs";
interface AccessJwtPayload {
sub: string;
email: string;
iss: string;
aud: string[];
exp: number;
iat: number;
identity_nonce: string;
type: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const assertion = request.headers.get("CF-Access-JWT-Assertion");
if (!assertion) {
return new Response(JSON.stringify({ error: "missing access token" }), {
status: 401,
headers: { "content-type": "application/json" },
});
}
const payload = await verifyAccessJwt(assertion, env.ACCESS_AUD);
if (!payload) {
return new Response(JSON.stringify({ error: "invalid access token" }), {
status: 403,
headers: { "content-type": "application/json" },
});
}
const enriched = new Request(request, {
headers: {
...Object.fromEntries(request.headers),
"x-verified-email": payload.email,
"x-verified-sub": payload.sub,
},
});
return fetch(enriched);
},
};
async function verifyAccessJwt(
token: string,
audience: string
): Promise<AccessJwtPayload | null> {
const parts = token.split(".");
if (parts.length !== 3) return null;
const header = JSON.parse(atob(parts[0].replace(/-/g, "+").replace(/_/g, "/")));
const payload: AccessJwtPayload = JSON.parse(
atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"))
);
const now = Math.floor(Date.now() / 1000);
if (payload.exp < now) return null;
if (!payload.aud.includes(audience)) return null;
if (payload.iss !== `https://your-team.cloudflareaccess.com`) return null;
const certsResponse = await fetch(CERTS_URL, {
cf: { cacheTtl: 3600, cacheEverything: true },
});
const { public_cert, public_certs } = await certsResponse.json<{
public_cert: { kid: string; cert: string };
public_certs: Array<{ kid: string; cert: string }>;
}>();
const allCerts = [public_cert, ...public_certs];
const matchingCert = allCerts.find((c) => c.kid === header.kid) ?? allCerts[0];
const pemBody = matchingCert.cert
.replace(/-----BEGIN CERTIFICATE-----/, "")
.replace(/-----END CERTIFICATE-----/, "")
.replace(/\s/g, "");
const certDer = Uint8Array.from(atob(pemBody), (c) => c.charCodeAt(0));
const cryptoKey = await crypto.subtle.importKey(
"spki",
certDer,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["verify"]
);
const signingInput = new TextEncoder().encode(`${parts[0]}.${parts[1]}`);
const signature = Uint8Array.from(
atob(parts[2].replace(/-/g, "+").replace(/_/g, "/")),
(c) => c.charCodeAt(0)
);
const valid = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", cryptoKey, signature, signingInput);
return valid ? payload : null;
}
The certificate fetch uses cf: { cacheTtl: 3600 } to leverage Cloudflare’s edge cache for the JWKS-equivalent certificate endpoint. Without this, every request triggers a subrequest to the Access certs endpoint, adding latency and hitting rate limits under load.
One subtle requirement: Workers must validate aud against your specific application audience tag, not just any Access token. Cloudflare Access issues tokens scoped to an application — a token for app-a.example.com must not be accepted by app-b.example.com. The audience parameter must come from environment configuration, not be hardcoded in the Worker bundle where it could be confused across applications.
Policy Distribution Without Redeployment
When authorization policy is compiled into the WASM filter binary, every policy change requires rebuilding and redeploying the binary. In a service mesh with hundreds of Envoy sidecars, this means a policy update takes as long as a full sidecar rollout — minutes to hours, not seconds.
There are two practical approaches to reducing this coupling.
Configuration-driven policy. Extract the parts of policy that change frequently — allowed audiences, permitted scopes, trusted issuers, IP allowlists — into the filter’s WASM configuration block rather than hard-coding them in the binary. Envoy passes this configuration to the filter via on_configure. The filter reads it on startup and can react to updates via the on_configure callback when Envoy performs an xDS configuration refresh.
impl RootContext for AuthRootContext {
fn on_configure(&mut self, _config_size: usize) -> bool {
if let Some(config_bytes) = self.get_plugin_configuration() {
if let Ok(config) = serde_json::from_slice::<FilterConfig>(&config_bytes) {
self.config = Some(config);
return true;
}
}
false
}
}
The filter configuration is part of the Envoy xDS resource, which can be updated without replacing the WASM binary. Istio and other control planes push xDS updates to sidecars within seconds. This works for policy parameters that can be expressed as data, but not for changes to the policy logic itself.
Data-driven OPA with refresh. Pre-compile the OPA Rego logic for structural policy (the rules), and store the data document (role assignments, resource attributes, allowlists) as the filter’s configuration. The root context refreshes the data document periodically via dispatch_http_call to an internal bundle server. Policy decisions use the fixed compiled logic applied to the refreshed data, giving near-live policy updates without binary redeployment. The security requirement is that the bundle fetch must be authenticated — unsigned or unauthenticated data documents can be substituted to override policy.
Limitations
No synchronous external calls. The dispatch_http_call host function is the only mechanism for a WASM filter to reach external services, and it is always asynchronous. The filter suspends the current request, receives a callback when the response arrives, and resumes processing. You cannot use this to make a synchronous PDP call inline in the request path. Any authorization logic that requires a live external lookup — checking a revocation list, querying a resource permission store — must be pre-cached in filter state via periodic background fetches. If the cache is stale or absent, the filter has two options: fail closed (reject the request) or fail open (permit without the external check). Fail open is not acceptable for authorization enforcement.
No shared state across workers. Envoy runs multiple worker threads, each processing requests independently. WASM filter instances are per-worker. Envoy’s shared data API (set_shared_data, get_shared_data) provides a string-keyed key-value store shared across all filter instances on all workers within the same Envoy process, but with coarse-grained compare-and-swap semantics. You can use shared data for JWKS caching and simple counters, but not for complex mutable state requiring transactional updates. Across multiple Envoy instances in a pod fleet, there is no shared state at all — each Envoy is an island.
WASM binary size constraints. Embedding large data into the WASM binary — a full RBAC role hierarchy, a large IP reputation list — increases the binary size and the memory footprint of each filter instance. Envoy’s default WASM VM memory limit is configurable, but each worker loads its own copy of the module. A 50 MB policy bundle with 10 worker threads means 500 MB of WASM VM memory for a single filter. Prefer fetching and caching data at runtime over embedding it in the binary.
Clock skew and token replay. The filter validates JWT expiry against the WASM VM’s clock, obtained via get_current_time_nanoseconds. This clock reflects the Envoy host’s system time. If the host clock drifts or is manipulated, token expiry enforcement fails silently — expired tokens appear valid, or fresh tokens appear expired. In environments where clock accuracy matters for security, the filter should add a configurable clock skew tolerance rather than treating expiry as a hard boundary.
mTLS peer cert availability. connection.peer_certificate is populated only after a successful mutual TLS handshake. If any upstream hop — a load balancer, an ingress controller — terminates TLS and re-originates the connection without forwarding the client certificate, the filter receives no peer certificate. SPIFFE ID validation in the filter is only sound if mTLS is maintained end-to-end from client to the filter’s Envoy instance. An architecture where TLS is terminated at the ingress and re-originated without client certificates cannot use this mechanism for workload identity enforcement at the sidecar.
These constraints are not reasons to avoid WASM-based authorization enforcement. They define the class of problems WASM filters solve well — stateless, locally-evaluable policy with cacheable external data — and the class where a dedicated external authorization service remains the right architecture.