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
arionbinary — see install. - An identity provider that issues JWTs and publishes a JWKS endpoint. The config uses
idp.example.comas 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
/apito theapicluster and retries5xxresponses 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.crtWalkthrough
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/usersHTTP/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/usersHTTP/1.1 200 OK
...
hello
The 200 requires a real issuer: a JWT whose
issishttps://idp.example.comand whose signature matches the JWKS the filter fetched. Pointissuer, the JWKSuriand theidp_jwkscluster 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: 300sbounds 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_authnon that route withtyped_per_filter_configandenvoy.config.route.v3.FilterConfigdisabled: true— see the filter chain. - Retrying
5xxre-sends requests that may have executed. If/apiaccepts writes, restrictretry_onor exclude non-idempotent methods at the client. - Watch the
idp_jwkscluster 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.