JWT pass-through validation
Already running an IdP? Tell assay-engine who to trust and it accepts JWTs
that IdP minted — verifying signatures against the upstream JWKS, refreshing keys in the
background, routing tokens by iss claim across multiple issuers. No engine
user table, no engine sessions, no schema migrations on the auth side.
What this is
The integration shape every operator who already runs an IdP wanted: keep your existing
identity stack (Hydra, Keycloak, Auth0, Okta, your in-house provider — anything that
publishes a standard /.well-known/openid-configuration discovery doc),
point assay-engine at it, and accept JWTs forwarded by a trusted edge. The
engine validates the signature against the upstream's published JWKS, checks
iss, aud, exp, and treats the request as that
user. Done.
What it isn't
This is not OIDC federation. There's no /login/<slug>
redirect, no PKCE, no callback handling. For that case (operators wanting the engine to
own the login flow against an upstream provider) see
OIDC provider quick-start — assay-engine ALSO
ships as a full IdP. The two modes coexist on one binary.
Why it matters
Most comparable auth stacks — Ory Hydra, Keycloak, the Auth0 SDKs — assume you bring your own edge that does the JWT validation and forwards. Few of them ship a single binary that does the validation natively, with JWKS caching, automatic refresh, multi-issuer routing, and a sensible default config — without making you stand up the IdP itself. That's the point of pass-through:
- Zero schema impact. The engine doesn't write to
auth.users,auth.sessions, or any other auth table when callers authenticate via pass-through. The upstream IdP is the source of truth. - Single binary. No sidecar JWKS proxy, no envoy filter, no nginx auth_request module. The same binary that runs your workflows / vault / dashboards is the one validating the JWT.
- Multi-issuer. Trust two or more IdPs simultaneously. Each is routed by
issclaim — no signature work for tokens from issuers we don't trust. - Automatic JWKS refresh. Keys are re-fetched on the configured interval (default 1h, minimum 60s) so upstream key rotation Just Works.
- Coexists with native auth. Engine-issued JWTs (from the OIDC provider mode) and pass-through tokens both flow through the same gate. Internal verification first, fall through to external verifiers, deny otherwise.
Configure it
One TOML block per trusted issuer. Empty list = pass-through disabled (existing
operator-user / admin_api_keys auth still works as before).
# engine.toml
auto_enable_modules = ["auth"]
[server]
bind_addr = "0.0.0.0:3000"
public_url = "${PUBLIC_URL}"
[backend]
type = "postgres"
url = "${DATABASE_URL}"
[[auth.external_issuers]]
issuer_url = "https://hydra.example.com/"
audience = ["my-app"]
jwks_refresh_secs = 3600 # default; minimum 60
# Multiple issuers? Add more blocks. Tokens are routed by `iss` claim.
[[auth.external_issuers]]
issuer_url = "https://login.partner.example.com/"
audience = ["my-app", "shared-with-partner"]
When [[auth.external_issuers]] is non-empty, the engine boots without
requiring operator users or admin_api_keys — the upstream IdP is the
source of truth. Add either of those alongside if you want a break-glass admin path
that survives upstream outage.
How verification flows
Client request
Authorization: Bearer eyJhbGc...
|
v
┌────────────────────────┐
│ Internal JWT verify │ Engine-issued tokens (OIDC provider mode)
│ assay_auth::jwt │ verify here first — no network round trip.
└────────────────────────┘
| failed (or no internal JWT config)
v
┌────────────────────────────────────┐
│ Peek at `iss` claim (unverified) │ Cheap base64 split. No crypto.
└────────────────────────────────────┘
|
v
┌────────────────────────────────────┐
│ Match `iss` to a configured │ Tokens from unconfigured
│ external issuer's `issuer_url` │ issuers fall through to 401.
└────────────────────────────────────┘
| matched
v
┌────────────────────────────────────┐
│ jsonwebtoken::decode against the │ Cached JWKS, looked up by kid.
│ matching issuer's cached JWKS │ Refreshed in the background.
└────────────────────────────────────┘
|
v
Caller { user_id: jwt.sub, source: Jwt }
Typical deployment shape
A reverse proxy (Traefik, nginx, Envoy, …) terminates user sessions against an upstream
OIDC provider and forwards an Authorization: Bearer <jwt> header to
assay-engine. The proxy doesn't need to validate the JWT itself — that's
the engine's job:
[[auth.external_issuers]]
issuer_url = "https://hydra.example.com/"
audience = ["my-app"]
This restores the exact integration model assay 0.12.1 supported via
--auth-issuer / --auth-audience CLI flags, in the new TOML
config shape, with multi-issuer routing and background JWKS refresh added.
Comparison with other approaches
| Approach | Components | JWKS handling | Multi-issuer |
|---|---|---|---|
nginx auth_request + JWT module |
nginx + njs / lua-resty-jwt + your app | Manual cache + refresh | Possible but config-heavy |
| Envoy JWT filter | Envoy + your app | Built-in, polling | Yes |
| OAuth2 Proxy in front | oauth2-proxy + your app | Handled by oauth2-proxy | One issuer per proxy |
| Keycloak / Hydra adapter SDK | Library inside your app | Library-managed | Per-app integration |
| assay-engine 0.3.2 pass-through | One binary | Built-in, background refresh, fail-soft | Yes — TOML block per issuer |
Operational notes
- Discovery is fail-fast at boot. If the upstream IdP is unreachable when the engine starts, boot fails with a clear message. The alternative — silently degrading to "no external issuer trusted" — would surface as 401s and look like a session bug.
- JWKS refresh is fail-soft. If a scheduled refresh fails the previous keys are kept and a WARN is logged. Stops a transient network blip from rejecting every request.
- Per-request cost is one base64 decode + one HMAC-or-RSA verify against an in-memory key set. Same shape as any nginx/envoy JWT filter.
- Audience can be empty for development setups where you don't want an
audcheck, but production deployments should always set it explicitly.
Implementation reference
- Source:
crates/assay-auth/src/external_jwt.rs— the verifier type, JWKS fetch + cache, multi-issuer routing. - Wire-up:
crates/assay-engine/src/lib.rs::discover_external_issuers— boot-time discovery for each configured issuer. - Auth gate:
crates/assay-auth/src/gate.rs::extract_caller— internal-JWT first, external-JWT fall-through, then 401. - Config:
crates/assay-engine/src/config.rs::ExternalIssuerConfig— TOML shape. - Tests: 9 unit tests in
external_jwt.rscovering happy path, wrong issuer, wrong audience, unknown kid, expired token, audience opt-out, multi-issuer routing, unknown-issuer fall-through, empty-list short-circuit.
Other ways into auth
- Auth & IdP overview — the wider story (passkey, OIDC client + provider, Zanzibar, biscuit)
- OIDC provider quick-start — assay-engine acting as the IdP
- Passkey flow — WebAuthn registration and login
- Zanzibar permissions — schema, tuples, check API
- Biscuit capability tokens — issue / verify / attenuate