Authentication (JWT/OIDC)
Feature-gated
The official release binaries and container include it (since 0.9.8, --features dist). A source build needs --features auth. A build without it refuses to start on a config whose routes set auth_profile, rather than serve them unauthenticated.
Zion supports per-route JWT validation as an optional authentication gate. Tokens are validated before the request reaches the upstream, preventing unauthorized access at the edge.
Configuration
HMAC (symmetric)
For internal microservices using shared secrets:
[auth_profile.internal]
# Prefer secret_env over a literal `secret` — it keeps the signing key out of
# zion.toml (and out of version control). It names an environment variable:
secret_env = "ZION_AUTH_INTERNAL_SECRET"
algorithm = "HS256"
issuer = "auth.internal"
audience = "api.internal"
forward_claims = trueKeep the HMAC secret out of the config file
A literal secret puts a live signing key in zion.toml — anyone who can read the file can forge valid tokens. Use secret_env (the name of an env var holding the secret); it wins over secret when both are set, and a named-but-missing/empty env var fails startup rather than silently continuing.
OIDC (asymmetric)
For external identity providers (Auth0, Keycloak, Okta):
[auth_profile.oidc]
jwks_url = "https://auth.example.com/.well-known/jwks.json"
algorithm = "RS256"
issuer = "https://auth.example.com/"
audience = "api.example.com"
forward_claims = trueRoute assignment
[[route]]
path = "/api/protected/{*rest}"
upstream = "backend"
auth_profile = "oidc"
waf = trueParameters
| Parameter | Type | Default | Description |
|---|---|---|---|
secret | string | — | HMAC shared secret literal (HS256/HS384/HS512). Deprecated: use secret_env. Zion warns at boot when it is set. It is redacted from debug output and wiped from memory when the config is dropped. |
secret_env | string | — | Name of an env var holding the HMAC secret. Preferred over secret; wins when both are set. The secret must be at least as long as the hash (RFC 7518 §3.2): 32 bytes for HS256, 48 for HS384, 64 for HS512; a shorter one is refused at boot and on reload (openssl rand -base64 48). |
previous_secret_env | string | — | Name of an env var holding the previous HMAC secret, accepted next to the current one during a key rotation. Remove it when the old tokens have expired. |
jwks_url | string | — | JWKS endpoint URL (for RS256/ES256, auto-refreshed hourly). Must be https:// (plain http:// only to a loopback address); anything else is refused. |
algorithm | string | HS256 | JWT algorithm. Auto-selects RS256 when jwks_url is set without secret |
issuer | string | — | Expected iss claim (optional) |
audience | string | — | Expected aud claim (optional) |
forward_claims | bool | true | Inject X-Auth-Subject and X-Auth-Email headers to upstream |
leeway_secs | integer | 30 | Clock-skew tolerance applied to exp/nbf. 0 is allowed; above 300 is rejected. |
max_token_lifetime_secs | integer | 86400 | Reject tokens whose exp is further in the future than this (plus leeway_secs): 403. Unset = 86400 (24 h) since 0.10.0 (before it: no cap). 0 = no cap, on purpose. A token accepted with more than 24 h left (under 0 or a cap above 24 h) is counted in zion_auth_long_lived_tokens_total. See Token lifetime and revocation. |
Supported algorithms
| Algorithm | Type | Use Case |
|---|---|---|
| HS256, HS384, HS512 | Symmetric (HMAC) | Internal microservices |
| RS256, RS384, RS512 | Asymmetric (RSA) | OIDC providers (Auth0, Keycloak) |
| ES256, ES384 | Asymmetric (ECDSA) | Modern OIDC providers |
Behavior
- Missing
Authorizationheader: Returns401 Unauthorized - Invalid/malformed token: Returns
403 Forbidden - Expired token: Returns
401 Unauthorizedwith bodytoken expired - Valid token: Request proceeds to upstream with optional claim headers
Claim forwarding
When forward_claims = true, decoded claims are injected as headers:
| Header | Claim | Description |
|---|---|---|
X-Auth-Subject | sub | User ID / subject |
X-Auth-Email | email | User email (if present in token) |
These headers are reserved: Zion strips any inbound X-Auth-Subject / X-Auth-Email from the client on every request (regardless of the auth feature or whether a route has an auth profile) before the gate re-injects the verified values, so an upstream can trust them as authenticated. A client cannot forge them.
Token lifetime and revocation
Zion validates a token's signature, expiry (exp), and not-before (nbf) on every request. It has no replay defense (a valid token can be presented any number of times within its lifetime) and no OIDC introspection. It does have a small per-instance revocation list keyed on the jti claim, described below.
Revoking a token
POST /admin/revoke on the admin API with {"jti":"<token id>","exp":<the token's exp, unix seconds>} denies that token id until exp; a request carrying it gets 403. Limits you should know about:
- Only a token that carries a
jtican be revoked. Issue one on every token. - The list is per instance: with several Zion instances you revoke on each. It is capped at 100 000 live entries.
- It is in memory unless
[admin] revocations_pathis set. Without it a restart makes every revoked token valid again until it expires. With it each revocation is written to that file before the API answers, and read back at boot (expired entries are dropped). If the file exists and cannot be read, zion refuses to start rather than start with nothing revoked. - It is a stop-gap for a leaked token, not a session system. For revocation that must span a fleet, keep tokens short-lived and rotate the key.
Rotating the signing key
Changing secret_env to a new key makes every outstanding token invalid at the reload. To rotate without that outage, keep the old key readable for a while:
[auth_profile.internal]
secret_env = "ZION_AUTH_SECRET" # the new key: what you sign with from now on
previous_secret_env = "ZION_AUTH_SECRET_OLD" # the old key: still accepted- Put the new key in
ZION_AUTH_SECRETand the old one inZION_AUTH_SECRET_OLD, setprevious_secret_env, restart or reload. Tokens signed with either verify. - Start signing with the new key.
- When the longest-lived old token has expired, remove
previous_secret_env.
A token is checked against the previous key only when the current one rejects its signature; expiry, issuer and audience are checked the same either way. The previous key must be set and as long as the current one (config validation), and the setting only makes sense with an HMAC secret.
Consequences for operators:
- Issue short-lived tokens. The token lifetime is your effective revocation window — a leaked token cannot be invalidated before
exp. Minutes, not days. Setmax_token_lifetime_secsto the longest lifetime you actually issue (for example900) and Zion rejects any token whoseexpis further out, so a mis-issued or forged-by-a-leaked-key token with a far-futureexpis refused instead of living for years.0means "no cap", said on purpose. - The default is 24 hours (since 0.10.0). A profile without
max_token_lifetime_secsrefuses a token whoseexpis more than86400seconds (plusleeway_secs) away; before 0.10.0 it accepted any. If you issue tokens that live longer (service tokens of 30 days, say), set the value you need, or0for no cap, before upgrading: otherwise those tokens get403after it. zion logs the first refusal each minute with the token's remaining lifetime and the cap (never the token), and notes at boot every profile that has no setting. The counterzion_auth_long_lived_tokens_totalnow counts the tokens that are still accepted past 24 h because of0or a larger cap: the long-lived tokens you chose to keep. audmay be a single string or an array (OIDC providers commonly send an array); the profile'saudiencemust appear in it.- A logout / key-compromise event cannot be enforced at the edge mid-lifetime; rotate the signing key (or JWKS) to invalidate outstanding tokens en masse.
- If revocation must be durable or fleet-wide, terminate auth at a service that maintains a denylist / introspection endpoint, and use Zion's gate as defense in depth.
JWKS refresh
- JWKS is fetched at startup and refreshed every 1 hour
- On fetch failure, retries with exponential backoff (5s, 10s, 20s, ... up to 1h)
- HTTP client failure is retried indefinitely (never gives up permanently)
- Clock skew tolerance: 30 seconds by default (
leeway_secs, at most 300)
Bearer token extraction
The Authorization header is parsed case-insensitively per RFC 6750 Section 2.1:
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...
Authorization: bearer eyJhbGciOiJSUzI1NiJ9... # also accepted
Authorization: BEARER eyJhbGciOiJSUzI1NiJ9... # also acceptedBuild
cargo build --release --features auth