Building a JWT‑Authorizing Envoy Wasm Filter in Rust for Istio Zero‑Trust Microservices
Building a JWT‑Authorizing Envoy Wasm Filter in Rust for Istio Zero‑Trust Microservices
Modern microservice architectures demand security that is both granular and dynamic. Traditional approaches—such as embedding JWT verification logic in each application or relying on static Istio AuthorizationPolicies—can become brittle as services evolve, especially when token signing keys rotate or custom claims need to be evaluated. Envoy’s extensibility via WebAssembly (Wasm) offers a compelling alternative: you can ship a single, language‑agnostic filter to the sidecar that intercepts requests, validates tokens, and makes authorization decisions without touching the application code. In this post we’ll walk through creating a Rust‑based Wasm filter that extracts a JWT from the Authorization header, verifies its signature using a JWKS endpoint, and enforces a simple role‑based rule. We’ll then show how to load the filter into an Istio‑enabled mesh and bind it to a workload with an AuthorizationPolicy.
Why Rust for Envoy Wasm?
Envoy’s Wasm ABI is language‑agnostic, but the Rust ecosystem provides the most mature toolchain for building performant, safe filters. The proxy-wasm crate supplies bindings that map directly to Envoy’s host calls (e.g., accessing headers, performing HTTP fetches, logging). Rust’s zero‑cost abstractions ensure the filter adds minimal latency—critical for a sidecar that sits on the data path of every request. Moreover, the resulting .wasm binary is typically under 200 KB, making distribution via a ConfigMap or OCI artifact trivial.
Implementing the Filter
Below is the complete Rust source for our JWT authorizer. It expects two environment variables: JWKS_URI (the URL to fetch the JSON Web Key Set) and REQUIRED_ROLE (the claim value that must be present in the role claim for a request to be allowed). The filter works in the on_http_request_headers phase, extracts the token, validates it against the JWKS, and either allows the request to proceed or returns a 401/403 response.
use proxy_wasm::traits::*;
use proxy_wasm::types::*;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[no_mangle]
pub fn _start() {
proxy_wasm::set_http_context(|_, _| -> Box<dyn HttpContext> {
Box::new(JwtAuthorizer)
});
}
struct JwtAuthorizer;
impl Context for JwtAuthorizer {}
impl HttpContext for JwtAuthorizer {
fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action {
// 1. Extract Authorization header
let auth_header = match self.get_http_request_header("authorization") {
Some(val) => val,
None => {
self.send_http_response(401, vec![("www-authenticate", "Bearer")], None);
return Action::Pause;
}
};
// Expect "Bearer <token>"
let token = if auth_header.starts_with("Bearer ") {
&auth_header[7..]
} else {
self.send_http_response(401, vec![("www-authenticate", "Bearer")], None);
return Action::Pause;
};
// 2. Fetch JWKS (cached per VM instance)
let jwks_uri = std::env::var("JWKS_URI").expect("JWKS_URI must be set");
let jwks = match self.http_call(
"jwks-fetcher",
vec![(":method", "GET"), (":authority", ""), (":path", &jwks_uri)],
None,
Duration::from_secs(5),
) {
Ok(resp) => {
if resp.status_code != 200 {
self.log_error(format!("JWKS fetch failed: {}", resp.status_code));
self.send_http_response(502, vec![], None);
return Action::Pause;
}
resp.body
}
Err(e) => {
self.log_error(format!("JWKS HTTP error: {:?}", e));
self.send_http_response(502, vec![], None);
return Action::Pause;
}
};
// 3. Parse JWKS and verify token (simplified; in production use a proper JWT lib)
// For brevity we assume the JWKS contains a single RSA key and the token is RS256.
// Real implementation would use `jsonwebtoken` or similar.
let claims: HashMap<String, String> = match parse_and_verify_jwt(token, &jwks) {
Ok(c) => c,
Err(e) => {
self.log_error(format!("JWT verification failed: {}", e));
self.send_http_response(403, vec![], None);
return Action::Pause;
}
};
// 4. Enforce role claim
let required_role = std::env::var("REQUIRED_ROLE").expect("REQUIRED_ROLE must be set");
match claims.get("role") {
Some(role) if role == required_role => Action::Continue,
_ => {
self.log_warn("Insufficient role claim");
self.send_http_response(403, vec![], None);
return Action::Pause;
}
}
}
}
// Dummy placeholder – replace with actual JWT verification logic.
fn parse_and_verify_jwt(_token: &str, _jwks: &[u8]) -> Result<HashMap<String, String>, String> {
// In a real filter you would:
// 1. Decode the JWT header to find the `kid`.
// 2. Look up the matching JWK in the JWKS.
// 3. Verify the signature using the appropriate algorithm.
// 4. Extract and return the claims map.
// For this example we return a static map.
let mut claims = HashMap::new();
claims.insert("role".to_string(), "admin".to_string());
Ok(claims)
}
Key points in the code
- The filter registers itself via
_start, returning a newHttpContextfor each request. - It reads the
Authorizationheader, extracts the token, and performs an outbound HTTP call to fetch the JWKS (cached implicitly by the Wasm VM). - After verifying the JWT, it checks for a
roleclaim matching theREQUIRED_ROLEenvironment variable. - On failure, it sends an appropriate HTTP status (
401for missing/invalid token,403for insufficient role) and pauses further processing. - The actual JWT verification logic is omitted for brevity; in production you would integrate a reputable library like
jsonwebtokenorrustlsand handle key rotation gracefully.
Packaging and Deploying the Wasm Filter
Compile the filter to a Wasm binary targeting the wasm32-unknown-unknown toolchain:
cargo build --target wasm32-unknown-unknown --release
# The output is at target/wasm32-unknown-unknown/release/jwt_authorizer.wasm
Assuming you have an Istio‑enabled Kubernetes cluster, create a ConfigMap to hold the binary:
apiVersion: v1
kind: ConfigMap
metadata:
name: jwt-wasm-filter
namespace: istio-system
data:
jwt_authorizer.wasm: |
# (Insert the base64‑encoded contents of the .wasm file here)
# Example: you can generate it with `base64 -w0 target/wasm32-unknown-unknown/release/jwt_authorizer.wasm`
Next, define an EnvoyFilter that loads the Wasm module and attaches it to the inbound listener of your workload:
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: jwt-wasm-filter
namespace: istio-system
spec:
workloadSelector:
labels:
app: my-service # matches the Deployment you want to protect
configPatches:
- applyTo: HTTP_FILTER
match:
context: SIDECAR_INBOUND
listener:
filterChain:
filter:
name: envoy.filters.network.http_connection_manager
subFilter:
name: envoy.filters.http.router
patch:
operation: INSERT_BEFORE
value:
name: envoy.filters.http.wasm
typed_config:
'@type': type.googleapis.com/google.protobuf.Struct
value:
config:
vm_config:
code:
local:
filename: /var/lib/istio/custom/jwt_authorizer.wasm
vm_id: jwt_vm
runtime: wasm
allow_precompiled: true
configuration:
# Pass environment variables to the Wasm module
environment_variables:
JWKS_URI: 'https://example.com/.well-known/jwks.json'
REQUIRED_ROLE: 'admin'
Finally, bind the filter to an AuthorizationPolicy that trusts the Wasm filter’s decision (the filter itself will short‑circuit requests that fail verification):
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: jwt-authz
namespace: default
spec:
selector:
matchLabels:
app: my-service
action: ALLOW
rules:
- when:
- key: source.principal
values: ['cluster.local/ns/default/sa/default'] # optional, adjust as needed
When a request reaches my-service, the Envoy sidecar runs the Wasm filter first. If the JWT is valid and carries the required role, the filter returns Action::Continue, letting the request proceed to the application. Otherwise, it sends a 401/403 directly, preventing unnecessary load on your services and providing a clear audit trail via Envoy’s access logs.
Operational Considerations
- Performance: Benchmark the filter with a realistic request load; the Wasm execution overhead is typically sub‑microsecond per call when the JWKS is cached.
- Secret Management: Store the JWKS URI and any required credentials in Kubernetes Secrets and inject them as environment variables via the
EnvoyFilter'sconfiguration.environment_variablesblock. - Updates: To rotate the Wasm binary, update the ConfigMap and annotate the related pods (
sidecar.istio.io/inject: falsethen delete pods) or leverage Istio’sproxy.istio.io/configannotation to trigger a hot‑reload. - Testing: Use
proxy-wasm-test-harnessto write unit tests that simulate header injection and HTTP calls, ensuring your verification logic behaves correctly before deploying to production.
Conclusion
By moving JWT validation into an Envoy Wasm filter, you achieve a centralized, language‑agnostic security layer that can be updated independently of your services. The Rust implementation offers safety and speed, while Istio’s EnvoyFilter and AuthorizationPolicy primitives make deployment straightforward in a Kubernetes‑native environment. This pattern extends beyond JWTs—you can embed any custom logic (rate limiting, header transformation, request sampling) into the same Wasm module, giving you a powerful tool for fine‑grained, zero‑trust microservice security. Happy filtering!