Skip to content

Build an API gateway

Put a rate limit and JWT authentication in front of an API, with retries and health checking on the upstream cluster.

Request path

What you will build

ARION 1listener2rate limit3jwt4route /api client cluster api

Outcome

This guide builds a small API gateway: a listener on port 8080 that rate-limits incoming traffic, verifies a JWT against your identity provider's keys, and forwards /api requests to a health-checked upstream cluster with automatic retries. Use it when an internal service needs a hardened front door before it faces clients.

Prerequisites

  • An arion binary — see install.
  • An identity provider that issues JWTs and publishes a JWKS endpoint. The config uses idp.example.com as a stand-in; substitute your issuer and JWKS URL.
  • An HTTP service to protect, and curl.

The architecture

  • One listener runs the HTTP connection manager with three HTTP filters in order: local rate limit, JWT authentication, router.
  • The route table sends /api to the api cluster and retries 5xx responses twice.
  • A second cluster, idp_jwks, exists only so the JWT filter can fetch signing keys from the identity provider over TLS.

The complete config

# arion.yaml, composed from 7 features.
logging:
  log_level: info
envoy_bootstrap:
  static_resources:
    listeners:
      - name: http_8080
        address:
          socket_address: { address: 0.0.0.0, port_value: 8080 }
        filter_chains:
          - name: http_8080_chain
            filters:
              - name: http_gateway
                typed_config:
                  "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                  http_filters:
                    - name: envoy.filters.http.local_ratelimit
                      typed_config:
                        "@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
                        stat_prefix: http_local_rate_limiter
                        token_bucket:
                          max_tokens: 100
                          tokens_per_fill: 100
                          fill_interval: 1s
                    - name: envoy.filters.http.jwt_authn
                      typed_config:
                        "@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
                        providers:
                          idp:
                            issuer: https://idp.example.com
                            from_headers:
                              - name: Authorization
                                value_prefix: "Bearer "
                            payload_in_metadata: jwt_payload
                            remote_jwks:
                              http_uri:
                                uri: https://idp.example.com/.well-known/jwks.json
                                cluster: idp_jwks
                                timeout: 5s
                              cache_duration: 300s
                        rules:
                          - match:
                              prefix: /
                            requires:
                              provider_name: idp
                    - name: envoy.filters.http.router
                      typed_config:
                        "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
                  route_config:
                    name: http_8080_routes
                    virtual_hosts:
                      - name: backend
                        domains: ["*"]
                        retry_policy:
                          retry_on: 5xx
                          num_retries: 2
                        routes:
                          - match:
                              prefix: /api
                            route:
                              cluster: api
    clusters:
      - name: api
        connect_timeout: 5s
        type: STATIC
        lb_policy: LEAST_REQUEST
        load_assignment:
          cluster_name: api
          endpoints:
            - lb_endpoints:
                - endpoint:
                    address:
                      socket_address: { address: 10.0.0.12, port_value: 8080 }
        health_checks:
          - timeout: 1s
            interval: 5s
            healthy_threshold: 3
            unhealthy_threshold: 2
            http_health_check:
              path: /healthz
      - name: idp_jwks
        connect_timeout: 5s
        type: STRICT_DNS
        load_assignment:
          cluster_name: idp_jwks
          endpoints:
            - lb_endpoints:
                - endpoint:
                    address:
                      socket_address: { address: idp.example.com, port_value: 443 }
        transport_socket:
          name: envoy.transport_sockets.tls
          typed_config:
            "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
            sni: idp.example.com
            common_tls_context:
              validation_context:
                trusted_ca:
                  filename: /etc/ssl/certs/ca-certificates.crt

Walkthrough

Filter order

The http_filters list runs top to bottom: local_ratelimit, then jwt_authn, then router. Order is a real decision here. With the limiter first, excess traffic is shed before Arion spends anything on signature verification or key fetches, so an unauthenticated flood cannot become a cryptography flood. The cost is that rejected 401 traffic consumes the same token budget as legitimate traffic — swap the two filters if you would rather count only authenticated requests.

The rate limit

The local rate limit filter holds a token bucket of max_tokens: 100, refilled with 100 tokens every second — a sustained 100 requests per second through this connection manager, with bursts up to 100. A request that finds the bucket empty is answered directly with 429, and never reaches the filters below. Variants, including per-route limits, are covered in rate limiting.

JWT authentication

The jwt_authn filter defines one provider, idp. It accepts tokens from the Authorization header with the Bearer prefix, requires the issuer https://idp.example.com, and stores the verified claims in dynamic metadata under jwt_payload for filters behind it to read. Keys come from remote_jwks: the JWKS document is fetched and then cached for 300s. The single rule — match prefix /, requires provider_name: idp — makes every path on this listener demand a valid token. Providers, rules and claim handling are explained in JWT authentication.

The JWKS cluster

remote_jwks.http_uri names not just a URL but a cluster: idp_jwks. The JWT filter is itself an HTTP client, and like every request Arion sends, its key fetches go through a cluster. idp_jwks is STRICT_DNS (it resolves idp.example.com at runtime) and carries a TLS transport socket with sni: idp.example.com and the system CA bundle, so the keys are fetched over verified HTTPS. No route points at this cluster; it exists purely for the filter.

Route, retries and the upstream

The virtual host matches all domains, retries 5xx twice, and routes prefix: /api to the api cluster — a STATIC, LEAST_REQUEST cluster with one endpoint, health-checked at /healthz every five seconds. A request outside /api that survives the filters above gets a 404 from the router. The cluster mechanics are the same as in the L7 load balancer guide.

Run and verify

For a local dry run, point the api cluster's endpoint at a local echo server (docker run -d --rm -p 5678:5678 hashicorp/http-echo -text=hello), then start Arion:

arion --config arion.yaml

A request without a token is rejected before routing:

curl -i http://127.0.0.1:8080/api/users
HTTP/1.1 401 Unauthorized

With a token from your identity provider, the same request reaches the upstream:

curl -i -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/api/users
HTTP/1.1 200 OK
...
hello

The 200 requires a real issuer: a JWT whose iss is https://idp.example.com and whose signature matches the JWKS the filter fetched. Point issuer, the JWKS uri and the idp_jwks cluster at your identity provider — or at a test JWKS you host — before expecting anything but 401.

The rate limit — and the filter order — can be verified without any token:

for i in $(seq 1 120); do curl -s -o /dev/null -w '%{http_code}\n' \
  http://127.0.0.1:8080/api/users; done | sort | uniq -c
  103 401
   17 429

The 401s reached the JWT filter and failed authentication; the 429s were cut off by the limiter before authentication ran. Exact counts depend on how fast the loop runs, since the bucket refills every second — burst faster and more requests hit 429.

Production notes

  • The 100 req/s bucket is per Arion process. Fleet-wide budgets need the global rate limit service — see rate limiting.
  • cache_duration: 300s bounds how long a revoked signing key keeps working. Shorten it for faster key rotation at the price of more JWKS fetches.
  • Every request on this listener needs a token — a request that no rule maps to a provider is rejected, so narrower rules cannot exempt a path. To expose a public path (a health endpoint, a docs page), disable jwt_authn on that route with typed_per_filter_config and envoy.config.route.v3.FilterConfig disabled: true — see the filter chain.
  • Retrying 5xx re-sends requests that may have executed. If /api accepts writes, restrict retry_on or exclude non-idempotent methods at the client.
  • Watch the idp_jwks cluster like any upstream: if key fetches fail past the cache lifetime, token verification fails closed and traffic sees 401s.

Next

Go deeper on JWT authentication and rate limiting, or add an authorization layer with RBAC. Field-level detail lives in the jwt_authn and local rate limit references.

Next

Take the configuration further

Continue

Start typing to search the site.

↑ ↓ to move Enter to open Esc to close