VirtualService Routing Fails After EnvoyFilter Injects a Header
What you see
In Istio, you use an EnvoyFilter to add a custom HTTP header (e.g. x-route-version: v2) and a VirtualService to match on that header and send traffic to a specific version. In practice, the route rule never applies and traffic always follows the default route.
Example setup:
EnvoyFilter — inject header
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: inject-route-header
spec:
workloadSelector:
labels:
app: frontend
configPatches:
- applyTo: HTTP_FILTER
match:
context: SIDECAR_OUTBOUND
listener:
filterChain:
filter:
name: envoy.filters.network.http_connection_manager
subFilter:
name: envoy.filters.http.router
patch:
operation: INSERT_BEFORE
value:
name: envoy.lua
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
inline_code: |
function envoy_on_request(request_handle)
request_handle:headers():add("x-route-version", "v2")
endVirtualService — match on header
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: my-service
spec:
hosts:
- my-service
http:
- match:
- headers:
x-route-version:
exact: v2
route:
- destination:
host: my-service
subset: v2
- route:
- destination:
host: my-service
subset: v1Expected: The frontend sidecar adds x-route-version: v2, the VirtualService matches it, and traffic goes to subset v2.
Actual: Traffic always goes to v1. Upstream can see x-route-version, but routing does not change.
Why it happens
Envoy matches routes before HTTP filters run
This follows Envoy’s internal pipeline; it is not an Istio bug.
Order of processing for an HTTP request:
Request arrives → HCM decodes headers → route resolution → HTTP filter chain → router forwardsIn more detail:
- Headers decoded:
HttpConnectionManager(HCM) finishes decoding HTTP headers. - Route match: HCM matches routes using the current headers (your VirtualService rules) and picks a cluster.
- HTTP filters: The filter chain runs (Lua, RBAC, etc.); only here does the EnvoyFilter add headers.
- Router:
envoy.filters.http.routerforwards using the cluster already chosen in step 2.
The issue: routing (step 2) completes before the Lua filter adds the header (step 3). After that, the route is fixed; new headers do not trigger a new match.
Why upstream still sees the injected header
The header is added in step 3, after routing in step 2. The router in step 4 forwards with the full header set, so upstream receives x-route-version: v2 even though the cluster was picked without it.
clear_route_cache
Envoy can re-run route matching after headers change if a filter calls clear_route_cache. A typical Lua EnvoyFilter does not call that, so the route is never refreshed.
How to verify
1. Confirm the header is injected
Log headers on the destination or curl through the sidecar:
kubectl exec -it <target-pod> -c istio-proxy -- \
curl -s http://localhost:<port>/headersIf you see x-route-version: v2, the filter works and the problem is ordering, not injection.
2. Inspect filter order
istioctl proxy-config listener <pod-name> -o json | \
jq '.[].filterChains[].filters[] | select(.name == "envoy.filters.network.http_connection_manager") | .typedConfig.httpFilters[].name'Confirm Lua runs before envoy.filters.http.router and after the other filters you care about.
3. Access logs at debug
istioctl proxy-config log <pod-name> --level http:debug,router:debugCheck route_name and cluster selection; at match time you should still see the original headers.
4. Confirm routes from the VirtualService
istioctl proxy-config route <pod-name> -o json | \
jq '.[] | select(.name == "80") | .virtualHosts[] | select(.name | contains("my-service"))'Mitigations
Option 1: Try to invalidate the route after Lua (limited)
After adding the header, force a re-match (behavior varies by Envoy version):
inline_code: |
function envoy_on_request(request_handle)
request_handle:headers():add("x-route-version", "v2")
request_handle:headers():replace(":path", request_handle:headers():get(":path"))
endNote: Lua’s
request_handledoes not exposeclearRouteCache()directly. Replacing:pathwith the same value is a hack to nudge route recalculation and is not guaranteed. Prefer Wasm orheader_to_metadatafor something reliable.
Option 2: Set the header in the client
Do not rely on EnvoyFilter for this header; set it in application code:
import requests
resp = requests.get(
"http://my-service:8080/api",
headers={"x-route-version": "v2"}
)Then the header exists when the sidecar runs route matching.
Option 3: Wasm filter with clear_route_cache
// proxy-wasm SDK
fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action {
self.add_http_request_header("x-route-version", "v2");
self.clear_route_cache();
Action::Continue
}Option 4: Header-to-metadata + metadata-based routing
Use Envoy’s header_to_metadata and route on metadata so ordering stays predictable.
Option 5: Inject at the gateway
If traffic passes through the ingress gateway, injecting there can change which headers exist before the next hop’s routing (two proxies, two HCM pipelines).
Summary
| Phase | What happens | Headers used for routing |
|---|---|---|
| HCM decode | Parse request | Original |
| Route match | Apply VirtualService | Original (EnvoyFilter not run yet) |
| HTTP filters | Lua/Wasm adds headers | Modified |
| Router | Forward to chosen cluster | Modified (route already fixed) |
Takeaway: Headers added by EnvoyFilter do not affect VirtualService matching because Envoy picks the route before those filters run. To make injected headers affect routing, trigger route recalculation (clear_route_cache / Wasm) or avoid relying on sidecar injection for that match.