---
title: "Build an API gateway"
url: "/guides/proxy/api-gateway/"
kind: "guide"
summary: "Put a rate limit and JWT authentication in front of an API, with retries and health checking on the upstream cluster."
---

> Arion site index: [/llms.txt](/llms.txt)

# 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.

## 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](@/docs/get-started/install.md).
- 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

````yaml
# 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](@/docs/traffic/rate-limiting.md).

### 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](@/docs/security/jwt.md).

### 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](@/guides/proxy/l7-load-balancer.md).

## 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:

```bash
arion --config arion.yaml
```

A request without a token is rejected before routing:

```bash
curl -i http://127.0.0.1:8080/api/users
```

```text
HTTP/1.1 401 Unauthorized
```

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

```bash
curl -i -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8080/api/users
```

```text
HTTP/1.1 200 OK
...
hello
```

> [!NOTE]
> 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:

```bash
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
```

```text
  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](@/docs/traffic/rate-limiting.md).
- `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](@/docs/concepts/filter-chain.md).
- 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](@/docs/security/jwt.md) and [rate limiting](@/docs/traffic/rate-limiting.md), or add an authorization layer with [RBAC](@/docs/security/rbac.md). Field-level detail lives in the [jwt_authn](@/reference/filters/http/jwt-authn.md) and [local rate limit](@/reference/filters/http/local-rate-limit.md) references.
