İçeriğe atla
BLOG_INDEX
7 Mart 2025·engineering·7 min read

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 new HttpContext for each request.
  • It reads the Authorization header, 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 role claim matching the REQUIRED_ROLE environment variable.
  • On failure, it sends an appropriate HTTP status (401 for missing/invalid token, 403 for insufficient role) and pauses further processing.
  • The actual JWT verification logic is omitted for brevity; in production you would integrate a reputable library like jsonwebtoken or rustls and 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's configuration.environment_variables block.
  • Updates: To rotate the Wasm binary, update the ConfigMap and annotate the related pods (sidecar.istio.io/inject: false then delete pods) or leverage Istio’s proxy.istio.io/config annotation to trigger a hot‑reload.
  • Testing: Use proxy-wasm-test-harness to 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!

#istio#wasm#security#envoy#rust
SHARE