Authentication Proxying & Token Validation
Authentication proxying establishes the outermost security boundary in any gateway architecture. Every inbound request is intercepted, its credential cryptographically verified, and only then forwarded to an upstream service — all inside the middleware chain that wraps every route, before transformation, before caching, and before the request touches business logic. Getting this layer right means platform teams can enforce uniform identity policies across dozens of microservices without embedding auth SDKs in each one; getting it wrong means a misconfigured plugin silently passes unsigned tokens to production.
Architectural Baseline
Before reading further, establish a clear mental model of what the gateway holds at validation time:
- The token — a compact credential (JWT, opaque OAuth2 token, or API key) carried in
Authorization: Bearer …or a custom header. - The trust anchor — for JWTs this is a JWKS endpoint or a PEM public key; for opaque tokens it is an introspection endpoint; for API keys it is a local lookup table or a remote credential store.
- The verified identity — a set of claims (
sub,scope,tenant_id,exp, …) the gateway extracts after verification and makes available to downstream plugins and upstream services via injected headers.
The gateway never trusts the token’s payload before verifying its signature (JWT) or checking it against the authoritative store (introspection). The order is always: parse → verify → extract → inject → forward.
This page covers the primary patterns for these steps across Kong (3.x), Envoy (1.32+), and NGINX with OpenResty. For the upstream transformation side — once the identity headers are injected — see request and response transformation.
JWT Validation at the Gateway
Envoy 1.32+ — jwt_authn HTTP filter
Envoy’s jwt_authn filter handles RS256 and ES256 verification natively without a sidecar or Lua. The filter fetches the JWKS on startup and caches it according to cache_duration. Configure the upstream that serves the JWKS separately so Envoy can apply retry and circuit-breaker policies to it.
# envoy.yaml — Envoy 1.32+
http_filters:
- name: envoy.filters.http.jwt_authn
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
providers:
primary_issuer:
issuer: "https://auth.platform.internal"
audiences:
- "https://api.platform.internal"
remote_jwks:
http_uri:
uri: "https://auth.platform.internal/.well-known/jwks.json"
cluster: auth_jwks_cluster
timeout: 2s
cache_duration:
seconds: 300
forward: false # strip Authorization before forwarding
forward_payload_header: "x-jwt-payload" # base64 claims → upstream
rules:
- match:
prefix: "/api/"
requires:
provider_name: primary_issuer
- match:
prefix: "/health"
allow_missing_or_failed: {} # bypass auth on health probes
clusters:
- name: auth_jwks_cluster
type: STRICT_DNS
connect_timeout: 1s
load_assignment:
cluster_name: auth_jwks_cluster
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: auth.platform.internal
port_value: 443
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
Key points:
forward: falsestrips theAuthorizationheader before the request reaches the upstream. This is the correct production posture — the upstream trusts the injectedx-jwt-payloadheader instead.forward_payload_headersends the decoded (but not re-signed) claims to the upstream as a base64url-encoded JSON object. The upstream reads this without making another crypto call.- The
allow_missing_or_failed: {}rule lets health-check paths through without credentials.
Kong 3.x — JWT plugin with claim header injection
Kong’s built-in jwt plugin verifies signatures and enforces exp, iss, and aud claims. Because the plugin’s request-transformer cannot directly interpolate JWT claim values into headers, claim forwarding requires a pre-function plugin that runs after jwt in the access phase.
# kong.yaml — Kong 3.x (deck format)
_format_version: "3.0"
services:
- name: protected-api
url: http://upstream.internal:8080
plugins:
- name: jwt
config:
claims_to_verify:
- exp
- iss
- aud
key_claim_name: kid # match public key by kid header
maximum_expiration: 3600 # reject tokens valid for more than 1 hour
run_on_preflight: false
- name: pre-function
config:
access:
- |
-- Runs after jwt plugin; kong.ctx.shared.authenticated_jwt_token is populated
local jwt_obj = kong.ctx.shared.authenticated_jwt_token
if jwt_obj then
local payload = jwt_obj.claims
kong.service.request.set_header("X-Auth-Sub", payload.sub or "")
kong.service.request.set_header("X-Auth-Tenant", payload.tenant or "")
kong.service.request.set_header("X-Auth-Scope", payload.scope or "")
kong.service.request.clear_header("Authorization")
end
routes:
- name: api-route
paths: ["/api/v1"]
strip_path: false
kong.ctx.shared.authenticated_jwt_tokenis set by the built-in JWT plugin when validation succeeds. The field name is stable across Kong 3.x. Do not rely onngx.ctxdirectly — use thekong.ctx.sharednamespace to avoid conflicts with other plugins.
Opaque Token Introspection and API Key Validation
Not every credential is a JWT. OAuth2 bearer tokens issued by some identity providers are opaque — a random string that must be validated against the authorization server’s /introspect endpoint. API keys are even simpler: they are looked up against a local credential store or a remote service.
Opaque token introspection (NGINX + OpenResty)
# nginx.conf — OpenResty 1.25+
http {
lua_shared_dict token_cache 10m;
server {
listen 8080;
location ~ ^/api/ {
access_by_lua_block {
local http = require("resty.http")
local cjson = require("cjson.safe")
local auth = ngx.var.http_authorization
if not auth then
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
local token = auth:match("^Bearer%s+(.+)$")
if not token then
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
-- Check local negative-cache to avoid hammering introspection on replay attacks
local cache = ngx.shared.token_cache
if cache:get("revoked:" .. token) then
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
local httpc = http.new()
httpc:set_timeout(1500) -- 1.5 s hard timeout
local res, err = httpc:request_uri("https://auth.platform.internal/introspect", {
method = "POST",
body = "token=" .. ngx.escape_uri(token),
headers = {
["Content-Type"] = "application/x-www-form-urlencoded",
["Authorization"] = "Basic " .. ngx.encode_base64("gateway-client:secret"),
},
ssl_verify = true,
})
if not res or res.status ~= 200 then
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
local payload = cjson.decode(res.body)
if not payload or not payload.active then
-- Cache the negative result for 30 s
cache:set("revoked:" .. token, true, 30)
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
-- Inject verified identity headers
ngx.req.set_header("X-Auth-Sub", payload.sub or "")
ngx.req.set_header("X-Auth-Scope", payload.scope or "")
ngx.req.set_header("X-Auth-Tenant", payload.ext and payload.ext.tenant or "")
ngx.req.clear_header("Authorization")
}
proxy_pass http://upstream_backend;
}
}
}
Introspection adds a per-request network round-trip. At high traffic volumes, use a short-lived local cache (30–60 s) for positive results and a negative cache for recently rejected tokens. The negative cache prevents a single bad token from hammering the introspection endpoint if an attacker replays it in a tight loop.
API key validation in Tyk 5.x
Tyk Gateway stores key definitions in Redis and validates them in-process — no external network call is needed per request. Configure use_keyless to false and add a custom middleware to inject the key’s metadata as headers:
{
"api_definition": {
"name": "Protected API",
"api_id": "protected-api-v1",
"version_data": {
"not_versioned": true,
"versions": {
"Default": {
"name": "Default",
"use_extended_paths": true
}
}
},
"auth": {
"auth_header_name": "X-API-Key",
"use_certificate": false
},
"use_keyless": false,
"enable_ip_whitelisting": false,
"proxy": {
"listen_path": "/api/v1/",
"target_url": "http://upstream.internal:8080",
"strip_listen_path": true
},
"custom_middleware": {
"pre": [
{
"name": "InjectKeyMeta",
"path": "/opt/tyk-gateway/middleware/inject_key_meta.js",
"require_session": true
}
]
}
}
}
The JavaScript middleware (inject_key_meta.js) reads request.session (the validated key object) and appends X-Auth-Tenant and X-Auth-Tier to the upstream request, keeping credential metadata out of the client-visible surface.
Comparative Implementation Table
| Gateway | Token type | Validation mechanism | Claims to upstream | Key trade-off |
|---|---|---|---|---|
| Envoy 1.32+ | JWT (RS256/ES256) | Native jwt_authn filter; remote JWKS with cache |
forward_payload_header base64 JSON |
Zero extra hop; JWKS cache must survive IdP downtime |
| Kong 3.x | JWT (HS256/RS256/ES256) | Built-in jwt plugin; keys stored in Kong DB or env |
pre-function Lua reads kong.ctx.shared |
Flexible claim injection but requires custom Lua for complex mapping |
| NGINX + OpenResty | Opaque OAuth2 | Lua resty.http to /introspect; lua_shared_dict cache |
ngx.req.set_header |
Full control; adds network RTT unless carefully cached |
| Tyk 5.x | API key | In-process Redis lookup; require_session middleware |
Custom JS middleware reads request.session |
Fastest for API keys; JWT support requires a separate plugin |
OAuth2 Token Lifecycle and Key Rotation
Short-lived access tokens (15–30 minutes) require a refresh strategy. In a gateway-centric architecture there are two approaches:
Client-driven refresh — the upstream service or the client itself exchanges a refresh token for a new access token when the current one approaches expiration. The gateway does not participate beyond validating each access token presented.
Gateway-driven token exchange — the gateway detects an exp within a configurable threshold (e.g., less than 60 s remaining), performs a grant_type=refresh_token exchange on behalf of the caller using a stored refresh token, and forwards the refreshed access token to the upstream. This pattern centralizes renewal logic and reduces client complexity, but requires the gateway to securely store refresh tokens — encrypted at rest (AES-256-GCM), scoped per credential, with revocation events propagated to all gateway nodes within 500 ms via Redis pub/sub.
For most deployments, client-driven refresh is simpler and more auditable. Gateway-driven exchange is justified only when clients are machine-to-machine services that cannot reliably implement refresh logic, or when the upstream is a legacy service that cannot handle token expiration gracefully.
Signing key rotation without a 401 storm
The diagram below shows the safe overlap window during RS256 key rotation. The critical rule is that the new key is published in the JWKS before the first token signed by it is issued, and the old key stays in the JWKS until every token issued under it has expired.
The safe rotation sequence is: publish new key → begin issuing new tokens → wait for old token lifetime to expire → remove old key. Gateways match verification keys by kid — if the old kid is removed while tokens issued under it are still in-flight, every one of those requests immediately returns 401.
Operational Gotchas
1. proxy_pass with a variable upstream in NGINX requires resolver
A common mistake: writing proxy_pass http://backend_${upstream_tenant} without declaring a resolver. NGINX resolves the host at config-load time, not per-request, so variable substitution in proxy_pass only works with resolver 127.0.0.53 valid=10s; and a pre-declared $target variable. Without this, NGINX fails to start or silently proxies to the wrong host.
# Correct pattern for dynamic upstream selection
resolver 127.0.0.53 valid=10s;
set $target "";
access_by_lua_block {
-- ... validate token ...
ngx.var.target = "http://tenant-" .. tenant_id .. ".internal:8080"
}
proxy_pass $target;
2. Kong request-transformer cannot read JWT claims directly
The built-in request-transformer plugin sets header values from static strings or from incoming request headers — it has no access to jwt.claims.* values. The documented workaround is either a pre-function plugin (shown above) or a custom Kong plugin that runs in the access phase after the jwt plugin. Attempting to use $(jwt.claims.sub) in a transformer config produces a literal string, not the claim value.
3. Clock skew causes spurious exp rejections
JWTs carry a numeric exp claim (Unix epoch). If the gateway server clock is more than the token’s grace window ahead of the issuing server, freshly issued tokens will appear expired. Ensure all nodes run NTP-synchronized clocks, and add a leeway parameter (60 s is typical) to the JWT validation config:
- Envoy:
clock_skew_seconds: 60under the provider - Kong: no built-in leeway option — set
maximum_expirationconservatively and synchronize clocks tightly
4. JWKS cache miss during IdP maintenance
If the JWKS endpoint returns a 5xx during a scheduled key rotation, Envoy continues to use the cached keys for up to cache_duration seconds. After the cache expires, Envoy will fail open or closed depending on require_provider_name semantics. Explicitly configure cache_duration to be longer than your planned maintenance window (use 600–900 s for routine rotations) and monitor jwks_fetch_error_total before any IdP maintenance.
5. Stripping Authorization before forwarding
If the Authorization header reaches the upstream service, a bug in that service could accidentally log or forward the raw token. Always strip it at the gateway after extracting claims, and inject scoped identity headers (X-Auth-Sub, X-Auth-Scope, X-Auth-Tenant) instead. This also enforces the trust boundary: the upstream should never need to re-verify the token.
6. Introspection round-trip latency at high concurrency
At more than ~500 RPS, a synchronous introspection call with a 1.5 s timeout can queue behind slow IdP responses and create a cascading backlog. Set a lua_shared_dict positive-result cache with a TTL matched to your token lifetime (e.g., 60 s) and a negative cache of 30 s for rejected tokens, as shown in the NGINX example above. Without caching, introspection latency directly adds to p99 request latency.
Observability
Authentication proxies generate high-signal telemetry. Capture the following at minimum:
Structured log fields per request:
auth.method—jwt,opaque,api_keyauth.result—pass,fail_signature,fail_expired,fail_scopeauth.latency_ms— time spent in validation (excluding JWKS fetch)auth.cache_hit— whether the JWKS or key was served from cacheauth.issuer— theissclaim value (for multi-issuer setups)
OpenTelemetry spans: Create child spans for token.parse, jwks.fetch (when a cache miss occurs), signature.verify, and claims.extract. Attach traceparent to upstream requests so the trace continues end-to-end. For the zero-trust security boundary model, these spans are the authoritative audit trail.
Prometheus metrics:
auth_validation_duration_seconds(histogram, labelled bymethod,result)jwks_cache_hit_ratio(gauge)jwks_fetch_errors_total(counter, labelled bystatus_code)token_expiry_seconds(histogram — distribution of remaining TTL at validation time)refresh_token_exchange_total(counter, labelled byresult)
Alert on jwks_fetch_errors_total rising above 0 for more than 60 seconds — this signals an IdP connectivity issue that will cause mass authentication failures when the cache expires.
Production Configuration Checklist
-
Authorizationheader is stripped before the request reaches the upstream service - JWKS
cache_durationis set to at least 300 s; tested behaviour during IdP downtime is documented - Clock skew leeway of 60 s is configured on the JWT validation filter
- Old signing
kidvalues remain in the JWKS for at least the maximum token lifetime during key rotation - A per-route bypass exists for health-check and readiness endpoints (
/health,/ready) - Failed auth attempts are counted against rate limiting quotas, not just forwarded requests
- Structured logs include
auth.result,auth.method,auth.latency_ms, andauth.cache_hiton every request - OpenTelemetry spans are emitted for
jwks.fetch(cache miss path) andsignature.verify - Prometheus alert rule fires when
jwks_fetch_errors_totalincreases for more than 60 s - Refresh token store (if used) is encrypted at rest and has a per-key rotation schedule
- Introspection positive-result cache TTL matches token lifetime; negative cache set to 30 s
FAQ
Should I validate JWTs at the gateway or inside each microservice?
Validate at the gateway for centralized policy enforcement and to avoid per-service SDK overhead. Each service should still verify the X-Auth-Sub or equivalent injected header is present and was set by the gateway (e.g., via a shared secret or mTLS between gateway and upstream), but the cryptographic signature check and JWKS fetch belong at the edge.
What happens when the JWKS endpoint is unreachable?
If the gateway cannot fetch or refresh the JWKS, it falls back to the cached key set. Configure a long enough cache_duration (300–600 s) and a circuit breaker on the JWKS upstream so a transient IdP outage does not block all traffic. Never set cache_duration to zero in production.
How do I safely rotate signing keys without causing 401 storms?
Implement dual-key validation: publish the new key in the JWKS under a new kid, keep the old kid active for the remaining token lifetime (typically 15–60 minutes), then remove the old kid only after all in-flight tokens have expired. Gateways that match by kid will accept both sets concurrently during the overlap window. See the timeline diagram above.
How should gateway-driven token refresh work in machine-to-machine flows?
The gateway detects an exp within a configurable threshold (e.g., less than 60 s remaining), performs a grant_type=refresh_token exchange on behalf of the caller using a stored refresh token, and forwards the refreshed access token to the upstream. Refresh tokens must be encrypted at rest and revocation events propagated to all gateway nodes within 500 ms via a pub/sub mechanism such as Redis. For most deployments, client-driven refresh is simpler and more auditable; gateway-driven exchange is worth the complexity only for machine-to-machine services that cannot reliably implement refresh logic themselves.
Related
- Implementing JWT Validation in Kong Plugins — step-by-step walkthrough of RS256/ES256 verification with JWKS caching in a custom Kong plugin
- Rotating JWT Signing Keys Without Downtime — overlapping-validity key rollover via multi-
kidJWKS endpoints - Routing by API Key vs JWT Claims — choosing the right credential type as the routing signal for path-based decisions
- CORS & Cross-Origin Security — preflight handling and the interaction between CORS headers and auth middleware order
- Implementing mTLS at the Gateway Edge — mutual TLS as the transport-layer complement to token-based identity