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.

Authentication proxy request flow Diagram showing a client sending a Bearer token to the API gateway. The gateway validates the token against a JWKS or introspection endpoint (dashed upward arrow), extracts claims, injects X-Auth-* headers, then either forwards the enriched request to the upstream service (solid right arrow) or returns 401 Unauthorized on failure (dashed downward arrow). Client Bearer token 1. request API Gateway 2. parse credential 3. verify signature 4. extract claims 5. inject X-Auth-* headers JWKS endpoint or /introspect 6. forward Upstream trusts headers 401 / 403 Unauthorized invalid or expired token

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: false strips the Authorization header before the request reaches the upstream. This is the correct production posture — the upstream trusts the injected x-jwt-payload header instead.
  • forward_payload_header sends 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_token is set by the built-in JWT plugin when validation succeeds. The field name is stable across Kong 3.x. Do not rely on ngx.ctx directly — use the kong.ctx.shared namespace 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.

Safe RS256 signing key rotation timeline A horizontal timeline with three rows showing: the old key (kid=v1) active from time 0, the new key (kid=v2) published at T1 and tokens starting to be issued under it, an overlap window where both keys are accepted by the gateway, and the old key removed after T1 plus the maximum token lifetime. Labels call out the 401-storm risk if the old key is removed too early. time T0 T1 (new key published) T1 + max token lifetime kid=v1 (old key) — active in JWKS kid=v2 (new key) — published, tokens issued overlap window — both kids accepted Removing kid=v1 here causes a 401 storm Safe to remove kid=v1 all old tokens 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: 60 under the provider
  • Kong: no built-in leeway option — set maximum_expiration conservatively 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.methodjwt, opaque, api_key
  • auth.resultpass, fail_signature, fail_expired, fail_scope
  • auth.latency_ms — time spent in validation (excluding JWKS fetch)
  • auth.cache_hit — whether the JWKS or key was served from cache
  • auth.issuer — the iss claim 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 by method, result)
  • jwks_cache_hit_ratio (gauge)
  • jwks_fetch_errors_total (counter, labelled by status_code)
  • token_expiry_seconds (histogram — distribution of remaining TTL at validation time)
  • refresh_token_exchange_total (counter, labelled by result)

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

  • Authorization header is stripped before the request reaches the upstream service
  • JWKS cache_duration is 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 kid values 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, and auth.cache_hit on every request
  • OpenTelemetry spans are emitted for jwks.fetch (cache miss path) and signature.verify
  • Prometheus alert rule fires when jwks_fetch_errors_total increases 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.


Up: Middleware Chains & Request Transformation