Loading content...
While path-based routing directs traffic based on where a request is going, header-based routing makes decisions based on who is making the request, what they're requesting, and how they're requesting it. HTTP headers carry rich contextual information—authentication tokens, client versions, content preferences, geographic hints, tenant identifiers—that enables sophisticated traffic management impossible with path matching alone.
In production systems serving millions of users, header-based routing enables:
This page provides a Principal Engineer's deep dive into header-based routing: the mechanics, the algorithms, and the production patterns that power modern API traffic management.
By the end of this page, you will master HTTP header semantics for routing, understand exact vs. regex header matching, implement multi-tenant and A/B testing patterns, configure canary deployments with header-based traffic splitting, and apply production-grade header routing configurations.
HTTP headers are key-value pairs in the request (and response) that carry metadata about the message. For routing purposes, we focus on request headers—information the client sends that the gateway can inspect to make routing decisions.
Anatomy of HTTP Headers:
GET /api/users/123 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1...
Content-Type: application/json
Accept: application/json
X-Tenant-ID: acme-corp
X-Client-Version: 2.3.1
X-Request-ID: 550e8400-e29b-41d4-a716-446655440000
X-Forwarded-For: 203.0.113.195
User-Agent: MyApp/2.3.1 (iOS 16.1; iPhone14,2)
Header Categories for Routing:
| Category | Example Headers | Routing Use Case |
|---|---|---|
| Authentication | Authorization, Cookie, X-API-Key | Route authenticated vs anonymous traffic |
| Content Negotiation | Accept, Accept-Language, Accept-Encoding | Route to service versions supporting requested format |
| Client Identification | User-Agent, X-Client-Version, X-App-Version | Route mobile vs web, legacy vs modern clients |
| Tenant/Organization | X-Tenant-ID, X-Organization-ID, Host | Multi-tenant routing to isolated infrastructure |
| Testing/Experimentation | X-Feature-Flag, X-Experiment-Cohort, X-Canary | A/B testing, canary deployments, feature flags |
| Tracing/Debugging | X-Request-ID, X-Correlation-ID, X-Debug | Route debug traffic to verbose logging services |
| Geographic | X-Forwarded-For, CF-IPCountry, X-Region | Regional routing, data residency compliance |
| Custom Business | X-Priority, X-SLA-Tier, X-Customer-Segment | Route premium customers to faster infrastructure |
Standard HTTP headers (Host, Accept, Authorization) have well-defined semantics. Custom headers (X-*) are application-specific. While the 'X-' prefix is technically deprecated (RFC 6648), it remains widely used to distinguish custom headers. Use clear, namespaced headers for custom routing needs: X-MyCompany-Tenant-ID rather than ambiguous Tenant.
Header Parsing Considerations:
Headers present parsing challenges that paths don't:
Content-Type = content-type), but values are typically case-sensitive1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// Robust header parsing for routing decisionsinterface ParsedHeaders { get(name: string): string | undefined; getAll(name: string): string[]; has(name: string): boolean;} class HeaderParser implements ParsedHeaders { private headers: Map<string, string[]> = new Map(); constructor(rawHeaders: Record<string, string | string[]>) { for (const [key, value] of Object.entries(rawHeaders)) { const normalizedKey = key.toLowerCase().trim(); const values = Array.isArray(value) ? value : [value]; // Handle comma-separated values (except for Set-Cookie which uses multiple headers) const parsed = normalizedKey === 'set-cookie' ? values : values.flatMap(v => v.split(',').map(s => s.trim())); this.headers.set(normalizedKey, parsed); } } get(name: string): string | undefined { const values = this.headers.get(name.toLowerCase()); return values?.[0]; } getAll(name: string): string[] { return this.headers.get(name.toLowerCase()) ?? []; } has(name: string): boolean { return this.headers.has(name.toLowerCase()); }} // Example: Parse Authorization header for routingfunction extractBearerToken(headers: ParsedHeaders): string | null { const auth = headers.get('authorization'); if (!auth) return null; const match = auth.match(/^Bearer\s+(.+)$/i); return match ? match[1] : null;} // Example: Parse tenant from host or custom headerfunction extractTenant(headers: ParsedHeaders): string | null { // Try custom header first const tenantHeader = headers.get('x-tenant-id'); if (tenantHeader) return tenantHeader; // Fall back to subdomain extraction const host = headers.get('host'); if (host) { const match = host.match(/^([^.]+)\.api\.example\.com$/); if (match) return match[1]; } return null;}Production gateways support multiple header matching strategies, each with distinct semantics and performance characteristics:
1. Exact Match
The header value must exactly equal the specified string.
match:
headers:
- name: X-Tenant-ID
exactMatch: "acme-corp"
Use case: Tenant isolation, feature flag targeting Performance: O(1) hash lookup, fastest
2. Prefix Match
The header value must start with the specified prefix.
match:
headers:
- name: User-Agent
prefixMatch: "Mozilla/5.0"
Use case: Browser detection, client version ranges Performance: O(prefix_length) string comparison
3. Suffix Match
The header value must end with the specified suffix.
match:
headers:
- name: User-Agent
suffixMatch: "Chrome"
Use case: Browser family detection Performance: O(suffix_length) string comparison
4. Regex Match
The header value must match a regular expression.
match:
headers:
- name: X-Client-Version
regexMatch: "^[0-9]+\\.[0-9]+\\.[0-9]+$"
Use case: Version pattern matching, complex conditions Performance: O(regex_complexity), can be slow
5. Present/Absent Match
Check if the header exists, regardless of value.
match:
headers:
- name: Authorization
presentMatch: true # Header must exist
Use case: Authenticated vs anonymous traffic Performance: O(1) hash lookup
6. Range Match (for numeric values)
The header's numeric value falls within a range.
match:
headers:
- name: X-Priority
rangeMatch:
start: 1
end: 100
Use case: Priority-based routing, SLA tiers Performance: O(1) numeric comparison
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// Comprehensive header matching implementationtype HeaderMatchType = | { type: 'exact'; value: string; caseSensitive?: boolean } | { type: 'prefix'; value: string } | { type: 'suffix'; value: string } | { type: 'regex'; pattern: RegExp } | { type: 'present'; inverted?: boolean } | { type: 'range'; start: number; end: number }; interface HeaderMatcher { name: string; match: HeaderMatchType;} function matchHeader( headers: ParsedHeaders, matcher: HeaderMatcher): boolean { const value = headers.get(matcher.name); const exists = value !== undefined; switch (matcher.match.type) { case 'present': return matcher.match.inverted ? !exists : exists; case 'exact': if (!exists) return false; return matcher.match.caseSensitive ? value === matcher.match.value : value.toLowerCase() === matcher.match.value.toLowerCase(); case 'prefix': return exists && value.startsWith(matcher.match.value); case 'suffix': return exists && value.endsWith(matcher.match.value); case 'regex': return exists && matcher.match.pattern.test(value); case 'range': if (!exists) return false; const num = parseFloat(value); return !isNaN(num) && num >= matcher.match.start && num < matcher.match.end; default: return false; }} // Combine multiple header matchers (AND logic)function matchAllHeaders( headers: ParsedHeaders, matchers: HeaderMatcher[]): boolean { return matchers.every(m => matchHeader(headers, m));} // Example usage:const matchers: HeaderMatcher[] = [ { name: 'X-Tenant-ID', match: { type: 'exact', value: 'acme-corp' } }, { name: 'Authorization', match: { type: 'present' } }, { name: 'X-Client-Version', match: { type: 'regex', pattern: /^2\.[0-9]+/ } },]; // Routes to premium-tier backend if:// - Tenant is acme-corp AND// - Request is authenticated AND// - Client version starts with 2.xRegex matching on high-traffic routes can become a performance bottleneck. Complex patterns with backtracking (e.g., .*.*.*) can cause catastrophic performance degradation (ReDoS). Always benchmark regex patterns, set timeout limits, and prefer simpler matching strategies when possible.
Multi-tenant SaaS applications must route requests from different customers to appropriate infrastructure. Header-based routing enables several architectural patterns:
Pattern 1: Shared Infrastructure with Logical Isolation
All tenants share backend services, but the gateway injects tenant context.
Client → Gateway → Shared Backend
↓
Inject X-Tenant-ID header
Pros: Cost-efficient, simpler operations Cons: Noisy neighbor risk, blast radius concerns
Pattern 2: Dedicated Infrastructure for Premium Tenants
Enterprise customers route to dedicated backends.
Client → Gateway → [Routing Decision]
↓
Premium Standard
↓ ↓
dedicated-backend shared-backend
Pros: Performance isolation, compliance (SOC2, dedicated resources) Cons: Higher cost, deployment complexity
Pattern 3: Regional Data Residency
Tenants route to backends in specific geographic regions for compliance.
EU Tenant → Gateway → EU Backend (Frankfurt)
US Tenant → Gateway → US Backend (Virginia)
Pros: GDPR compliance, data sovereignty Cons: Cross-region complexity, latency for global tenants
12345678910111213141516171819202122232425262728293031323334353637383940
# Envoy route configuration for multi-tenant routingroutes: # Premium tenant: dedicated infrastructure - match: prefix: "/api/" headers: - name: "x-tenant-id" exact_match: "enterprise-acme" route: cluster: acme-dedicated-cluster timeout: 30s retry_policy: num_retries: 3 # Enterprise tier: dedicated cluster group - match: prefix: "/api/" headers: - name: "x-tenant-tier" exact_match: "enterprise" route: cluster: enterprise-cluster-pool # Higher timeouts for enterprise timeout: 60s # Regional routing for EU tenants (GDPR) - match: prefix: "/api/" headers: - name: "x-tenant-region" exact_match: "eu" route: cluster: eu-west-1-cluster # Default: shared infrastructure - match: prefix: "/api/" route: cluster: shared-cluster timeout: 10sThe gateway should resolve tenant identity early in the request lifecycle, before routing decisions. Common strategies: (1) Custom header from client (X-Tenant-ID), (2) Subdomain extraction (acme.api.example.com), (3) Path segment (/tenants/acme/...), (4) JWT claim extraction. Normalize to a standard header (X-Resolved-Tenant-ID) for downstream routing.
Header-based routing is the infrastructure foundation for A/B testing and feature flag systems. By routing requests based on experiment cohort headers, you can direct users to different service versions without client-side changes.
The A/B Testing Flow:
1. User visits application
2. Client-side code assigns user to experiment cohort
3. Client includes cohort in request header: X-Experiment-Cohort: search-v2-treatment
4. Gateway routes to experimental backend
5. Backend serves experimental experience
6. Analytics tracks cohort performance
Implementation Approaches:
Approach 1: Client-Assigned Cohorts
Approach 2: Gateway-Assigned Cohorts
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
// Gateway-based A/B cohort assignment and routingimport crypto from 'crypto'; interface Experiment { id: string; feature: string; // Feature being tested variants: ExperimentVariant[]; salt: string; // Experiment-specific salt targeting?: TargetingRule[]; // Optional targeting rules} interface ExperimentVariant { id: string; weight: number; // 0-100 percentage backend: string; // Target backend cluster} interface TargetingRule { type: 'header' | 'percentage' | 'userList'; // Rule-specific config} /** * Deterministic cohort assignment using consistent hashing * Same user always gets same variant for an experiment */function assignCohort( userId: string, experiment: Experiment): ExperimentVariant { // Create deterministic hash from user + experiment const hash = crypto .createHash('sha256') .update(`${userId}:${experiment.id}:${experiment.salt}`) .digest(); // Convert to number in range [0, 100) const bucket = (hash.readUInt32BE(0) % 10000) / 100; // Find variant based on weight distribution let cumulative = 0; for (const variant of experiment.variants) { cumulative += variant.weight; if (bucket < cumulative) { return variant; } } // Fallback to last variant return experiment.variants[experiment.variants.length - 1];} /** * Route request based on experiment configuration */function routeExperiment( headers: ParsedHeaders, experiments: Experiment[]): { backend: string; experimentHeaders: Record<string, string> } { const experimentHeaders: Record<string, string> = {}; let selectedBackend: string | null = null; // Check for explicit cohort header first (client-assigned) const explicitCohort = headers.get('x-experiment-cohort'); if (explicitCohort) { // Validate cohort exists and get backend // ... validation logic experimentHeaders['X-Experiment-Source'] = 'client'; } // Extract user identifier for cohort assignment const userId = extractUserId(headers); if (!userId) { return { backend: 'default-cluster', experimentHeaders }; } // Evaluate each active experiment for (const experiment of experiments) { // Check targeting rules (e.g., internal-only, percentage rollout) if (!evaluateTargeting(headers, experiment.targeting)) { continue; } const variant = assignCohort(userId, experiment); experimentHeaders[`X-Experiment-${experiment.id}`] = variant.id; // First matching experiment determines backend // (or implement priority/combination logic) if (!selectedBackend) { selectedBackend = variant.backend; } } experimentHeaders['X-Experiment-Source'] = 'gateway'; return { backend: selectedBackend || 'default-cluster', experimentHeaders, };} function extractUserId(headers: ParsedHeaders): string | null { // Try JWT first const auth = headers.get('authorization'); if (auth?.startsWith('Bearer ')) { // Decode JWT and extract user_id claim // ... JWT decoding } // Fall back to session cookie const cookies = headers.get('cookie'); // ... cookie parsing return null;}Canary deployments gradually shift traffic from a stable version to a new version, monitoring for regressions. Header-based routing enables precise control over which requests hit the canary.
Canary Strategies:
Strategy 1: Opt-In Canary (Header Flag)
Only requests with a specific header hit the canary.
X-Canary: true → canary-backend
(no header) → stable-backend
Use case: Internal testing, developer preview Risk: Very low (only intentional traffic)
Strategy 2: Percentage-Based Canary
A percentage of requests route to canary based on hash.
5% of requests → canary-backend
95% of requests → stable-backend
Use case: Gradual rollout with real traffic Risk: Proportional to percentage
Strategy 3: User-Based Canary
Specific users (by ID or attribute) hit the canary.
Internal users → canary-backend
Enterprise users → stable-backend (protected)
Others → percentage-based
Use case: Staged rollout with risk management
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
# Istio VirtualService for canary with header overrideapiVersion: networking.istio.io/v1beta1kind: VirtualServicemetadata: name: user-servicespec: hosts: - user-service http: # Rule 1: Explicit canary header routes to canary - match: - headers: x-canary: exact: "true" route: - destination: host: user-service subset: canary weight: 100 # Rule 2: Internal users (by header) get canary - match: - headers: x-user-type: exact: "internal" route: - destination: host: user-service subset: canary weight: 100 # Rule 3: Weighted split for remaining traffic - route: - destination: host: user-service subset: stable weight: 95 - destination: host: user-service subset: canary weight: 5---# DestinationRule defines subsetsapiVersion: networking.istio.io/v1beta1kind: DestinationRulemetadata: name: user-servicespec: host: user-service subsets: - name: stable labels: version: v1.2.3 - name: canary labels: version: v1.3.0-canaryWithout session stickiness, a user may alternate between canary and stable versions across requests, causing inconsistent experiences. Use consistent hashing on user ID to ensure the same user always hits the same version during the canary period.
Canary Rollout Automation:
Mature organizations automate canary progression:
1. Deploy canary with 0% traffic
2. Route internal users to canary
3. Monitor error rates, latency for 30 minutes
4. If healthy: increase to 5% traffic
5. Monitor for 1 hour
6. If healthy: increase to 25%, then 50%, then 100%
7. If unhealthy at any stage: automatic rollback to 0%
Tools like Argo Rollouts, Flagger, and Spinnaker implement this pattern with configurable health checks and automatic progression.
The Accept family of HTTP headers enables content negotiation—clients indicate their preferred response format, and the server (or gateway) selects an appropriate response variant.
Standard Content Negotiation Headers:
| Header | Purpose | Example Values |
|---|---|---|
| Accept | Preferred media types | application/json, application/xml, text/html |
| Accept-Language | Preferred languages | en-US, fr-FR, de |
| Accept-Encoding | Preferred compression | gzip, br, identity |
| Accept-Charset | Preferred character sets | utf-8, iso-8859-1 |
Gateway-Level Content Routing:
Rather than every backend implementing content negotiation, the gateway can route to specialized services:
Accept: application/json → json-api-service
Accept: application/xml → xml-api-service (legacy)
Accept: text/html → ui-service
This pattern is especially useful when:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
// Parse and evaluate Accept header with quality valuesinterface MediaType { type: string; // e.g., "application" subtype: string; // e.g., "json" quality: number; // 0.0 - 1.0, default 1.0 params: Record<string, string>;} /** * Parse Accept header into ranked media types * Example: "application/json, application/xml;q=0.9, */*;q=0.1" */function parseAcceptHeader(accept: string): MediaType[] { const mediaTypes: MediaType[] = []; for (const part of accept.split(',')) { const [mediaRange, ...paramParts] = part.trim().split(';'); const [type, subtype] = mediaRange.split('/'); const params: Record<string, string> = {}; let quality = 1.0; for (const param of paramParts) { const [key, value] = param.trim().split('='); if (key === 'q') { quality = parseFloat(value) || 1.0; } else { params[key] = value; } } mediaTypes.push({ type: type.trim(), subtype: subtype?.trim() || '*', quality, params, }); } // Sort by quality, descending return mediaTypes.sort((a, b) => b.quality - a.quality);} /** * Select best matching backend based on Accept header */interface ContentRoute { mediaType: string; // e.g., "application/json" backend: string;} function routeByContentType( acceptHeader: string | undefined, routes: ContentRoute[], defaultBackend: string): string { if (!acceptHeader) { return defaultBackend; } const preferences = parseAcceptHeader(acceptHeader); for (const pref of preferences) { for (const route of routes) { const [routeType, routeSubtype] = route.mediaType.split('/'); // Check for match (considering wildcards) const typeMatch = pref.type === '*' || pref.type === routeType; const subtypeMatch = pref.subtype === '*' || pref.subtype === routeSubtype; if (typeMatch && subtypeMatch) { return route.backend; } } } return defaultBackend;} // Example usage:const contentRoutes: ContentRoute[] = [ { mediaType: 'application/json', backend: 'json-api' }, { mediaType: 'application/xml', backend: 'xml-legacy-api' }, { mediaType: 'text/html', backend: 'web-ui' },]; // Accept: application/json → json-api// Accept: text/html, application/json;q=0.9 → web-ui// Accept: */* → json-api (first defined)Some APIs use custom media types for versioning: Accept: application/vnd.myapi.v2+json. The gateway can parse the version from the media type and route to the appropriate backend version, keeping URLs clean while supporting multiple API versions.
Real-world routing configurations combine path and header matching. The gateway evaluates multiple conditions and selects the most specific matching route.
Evaluation Order:
Example: Complex Routing Matrix
Consider an API with:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
# Complex routing combining path and header conditionsroutes: # Priority 1: Enterprise tenant on v2 canary (explicit opt-in) - match: prefix: "/api/v2" headers: - name: "x-tenant-tier" exact_match: "enterprise" - name: "x-canary" exact_match: "true" route: cluster: v2-enterprise-canary # Priority 2: Enterprise tenant on v2 stable - match: prefix: "/api/v2" headers: - name: "x-tenant-tier" exact_match: "enterprise" route: cluster: v2-enterprise-stable # Priority 3: v2 canary (percentage-based for standard tenants) - match: prefix: "/api/v2" route: weighted_clusters: clusters: - name: v2-standard-stable weight: 95 - name: v2-standard-canary weight: 5 # Priority 4: v1 for enterprise (legacy support) - match: prefix: "/api/v1" headers: - name: "x-tenant-tier" exact_match: "enterprise" route: cluster: v1-enterprise # Priority 5: v1 default - match: prefix: "/api/v1" route: cluster: v1-standard # Priority 6: Root redirect to v2 - match: prefix: "/api" headers: - name: "accept" prefix_match: "application/json" redirect: path_redirect: "/api/v2" response_code: TEMPORARY_REDIRECT| Request Path | Tenant Tier | Canary Header | Resolved Backend |
|---|---|---|---|
| /api/v2/users | enterprise | true | v2-enterprise-canary |
| /api/v2/users | enterprise | (none) | v2-enterprise-stable |
| /api/v2/users | standard | (none) | v2-standard-stable (95%) or canary (5%) |
| /api/v1/users | enterprise | (any) | v1-enterprise |
| /api/v1/users | standard | (any) | v1-standard |
As routing rules grow, configuration becomes hard to reason about. Consider: (1) Generating routing config from a higher-level DSL, (2) Visualizing routing as a decision tree, (3) Testing routing with a comprehensive test matrix, (4) Documenting the routing priority hierarchy.
Header-based routing in production introduces considerations beyond path routing:
1. Header Size Limits
HTTP specifications don't mandate header size limits, but servers enforce them:
Large headers (JWTs, cookies) can cause 431 "Request Header Fields Too Large" errors.
2. Header Injection Security
Malicious clients can send arbitrary headers. Never trust client headers for:
3. Header Propagation
Decide which headers the gateway forwards to backends:
1234567891011121314151617181920212223242526272829303132
# Envoy header manipulation for securityhttp_filters: - name: envoy.filters.http.lua typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua inline_code: | function envoy_on_request(request_handle) -- Remove potentially spoofed internal headers request_handle:headers():remove("x-internal-user-id") request_handle:headers():remove("x-admin-override") -- Validate tenant header if present local tenant = request_handle:headers():get("x-tenant-id") if tenant and not tenant:match("^[a-z0-9%-]+$") then request_handle:respond({[":status"] = "400"}, "Invalid tenant ID") return end -- Add request tracing local request_id = request_handle:headers():get("x-request-id") if not request_id then request_id = generate_uuid() request_handle:headers():add("x-request-id", request_id) end end - name: envoy.filters.http.router # Request header size limitscommon_http_protocol_options: max_headers_count: 100 max_stream_duration: 30sHeader-based routing extends the gateway's decision-making beyond paths to rich request context. It enables sophisticated traffic management patterns that are essential for modern multi-tenant, experimentally-driven systems.
You now understand header-based routing comprehensively—from parsing semantics to production multi-tenant and experimentation patterns. The next page covers request transformation, where the gateway modifies requests before forwarding to backends.