API gateway authentication: putting auth at the edge vs in the app

When you have a single API service, adding JWT validation middleware to your Express or FastAPI app is straightforward. When you have twenty microservices, replicating that middleware across all of them becomes a maintenance problem: every service needs to stay synchronized with the current JWKS endpoint, every service might implement the validation slightly differently, and rotating your signing key requires touching every service. An API gateway that handles authentication centrally is an appealing alternative — but it comes with its own tradeoffs.

Gateway-level JWT validation with Kong

Kong is the most common open-source API gateway with a mature JWT plugin. Configuring it to validate tokens and forward claims to upstream services is straightforward.

# Kong declarative config (deck format)
services:
  - name: user-service
    url: http://user-service:3000

routes:
  - name: user-api
    service: user-service
    paths: ["/api/users"]
    strip_path: false

plugins:
  - name: jwt
    service: user-service
    config:
      uri_param_names: []         # Only allow Bearer header, not query param
      cookie_names: []
      key_claim_name: kid         # Use the 'kid' header to select the consumer key
      claims_to_verify:
        - exp                     # Verify expiry
        - nbf                     # Verify not-before
      secret_is_base64: false
      run_on_preflight: false

After validation, Kong can forward claims to the upstream service as HTTP headers. This requires the upstream to trust these headers, which is safe only if the upstream is not reachable from outside the gateway.

# Kong plugin: forward JWT claims as headers to upstream
plugins:
  - name: request-transformer
    service: user-service
    config:
      add:
        headers:
          # Forward the verified sub claim — Kong has already validated the token
          - "X-User-ID:$(jwt_claims.sub)"
          - "X-User-Org:$(jwt_claims.org)"
          - "X-User-Roles:$(jwt_claims.roles)"
      remove:
        headers:
          # Strip the raw Authorization header so upstream cannot see the token
          - Authorization

Nginx JWT validation with lua-resty-jwt

For Nginx-based setups (including OpenResty), JWT validation is done in Lua. The lua-resty-jwt library handles RS256 and HS256 verification.

-- nginx.conf: validate JWT in access phase
access_by_lua_block {
  local jwt = require "resty.jwt"
  local cjson = require "cjson"

  local auth_header = ngx.req.get_headers()["Authorization"]
  if not auth_header or not string.match(auth_header, "^Bearer ") then
    ngx.status = 401
    ngx.say(cjson.encode({error = "missing_authorization_header"}))
    return ngx.exit(401)
  end

  local token = string.sub(auth_header, 8)  -- strip "Bearer "

  -- Load public key from file (rotated periodically)
  local f = io.open("/etc/nginx/jwt_public_key.pem", "r")
  local public_key = f:read("*all")
  f:close()

  local jwt_obj = jwt:verify(public_key, token)

  if not jwt_obj.verified then
    ngx.status = 401
    ngx.say(cjson.encode({error = "invalid_token", detail = jwt_obj.reason}))
    return ngx.exit(401)
  end

  -- Validate audience
  local aud = jwt_obj.payload.aud
  if aud ~= "https://api.example.com" then
    ngx.status = 403
    ngx.say(cjson.encode({error = "invalid_audience"}))
    return ngx.exit(403)
  end

  -- Forward claims as headers to upstream
  ngx.req.set_header("X-User-ID", jwt_obj.payload.sub)
  ngx.req.set_header("X-User-Org", jwt_obj.payload.org or "")
}

Header forwarding and upstream trust

A critical security constraint: upstream services must only accept identity headers from the gateway, never from the public internet. If a user can reach http://user-service:3000 directly and set X-User-ID: admin, they bypass authentication entirely.

Enforce this with network policy. In Kubernetes, use NetworkPolicy resources to restrict ingress to each service to only the gateway's pod CIDR. In AWS, use security groups to allow inbound traffic to service ports only from the gateway's security group. The application layer should additionally strip or reject these headers if they arrive on a request that has no gateway validation marker.

Latency tradeoffs

Gateway-level JWT validation is fast — signature verification is CPU-bound and takes under 1ms for RS256 with a 2048-bit key. The overhead is similar to doing the same verification in your application code. The latency win comes from eliminating per-service JWKS fetching: the gateway fetches and caches the JWKS once, while each of twenty services doing it separately would each add occasional cache-miss latency and put load on the JWKS endpoint.

The latency cost of gateway auth is an additional network hop for every request: client to gateway to upstream, versus client directly to upstream. In a well-configured cluster, this adds 0.5–2ms per request. For services already behind a load balancer, this is often negligible because the load balancer was already adding that hop.

Gateway auth does not replace all authorization logic in upstream services. The gateway validates the token and confirms the caller is authenticated. Whether the authenticated caller is allowed to access a specific resource (row-level permissions, organization scope checks, ownership validation) must still happen in the service. The gateway handles AuthN; the service handles AuthZ.

Route-level auth configuration

Not all routes need authentication. Health check endpoints, webhook receivers, and public API endpoints should be reachable without a token. Configure the gateway to apply JWT validation selectively by route prefix or route tag.

# Kong: disable JWT auth for specific routes
routes:
  - name: health-check
    service: user-service
    paths: ["/health", "/ready"]
    plugins:
      - name: jwt
        config:
          run_on_preflight: false
        enabled: false  # Explicitly disabled for this route

  - name: webhooks
    service: user-service
    paths: ["/webhooks"]
    plugins:
      - name: jwt
        enabled: false
      - name: hmac-auth  # Use HMAC signature instead for webhooks
        config:
          hide_credentials: true

Token refresh at the gateway

Handling token refresh at the gateway is possible but complex and usually not worth it. The fundamental problem is that the gateway typically operates statelessly — it validates tokens but does not manage sessions or refresh token storage. Pushing refresh token handling into the gateway requires the gateway to hold secrets, make calls to the authorization server, and handle the race condition where concurrent requests with the same expired token all try to refresh simultaneously. These concerns are better handled in the client and, for server-side sessions, in a dedicated session management service. Keep the gateway focused on validation and routing.

← Back to blog Try Bastionary free →