High-performance async JWKS cache with ETag revalidation, early refresh, and multi-tenant support — built for modern Rust identity systems.
- Why jwks-cache?
- Installation
- Quick Start
- Validating Tokens
- Registry Configuration
- Observability
- Persistence & Warm Starts
- Development
- Support
- Acknowledgements
- License
- HTTP-aware caching: honours
Cache-Control,Expires,ETag, andLast-Modifiedheaders viahttp-cache-semantics, so refresh cadence tracks the upstream contract instead of guessing TTLs. - Resilient refresh loop: background workers use single-flight guards, exponential backoff with jitter, and bounded stale-while-error windows to minimise pressure on identity providers.
- Multi-tenant registry: isolate registrations per tenant, enforce HTTPS, and restrict redirect targets with domain allowlists or SPKI pinning.
- Built-in observability: metrics, traces, and status snapshots are emitted with tenant/provider labels to simplify debugging and SLO tracking.
- Optional persistence: Redis-backed snapshots allow the cache to warm-start without stampeding third-party JWKS endpoints after deploys or restarts.
Add the crate to your project and enable optional integrations as needed:
# Cargo.toml
[dependencies]
# Drop `redis` if persistence is unnecessary.
jwks-cache = { version = "0.1", features = ["redis"] }
jsonwebtoken = { version = "10.1" }
metrics = { version = "0.24" }
reqwest = { version = "0.12", features = ["http2", "json", "rustls-tls", "stream"] }
tracing = { version = "0.1" }
tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "sync", "time"] }The crate is fully async and designed for the Tokio multi-threaded runtime.
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
// Optional Prometheus exporter (metrics are always sent via the `metrics` facade).
jwks_cache::install_default_exporter()?;
let registry = jwks_cache::Registry::builder()
.require_https(true)
.add_allowed_domain("tenant-a.auth0.com")
.with_redis_client(redis::Client::open("redis://127.0.0.1/")?)
.build();
let mut registration = jwks_cache::IdentityProviderRegistration::new(
"tenant-a",
"auth0",
"https://tenant-a.auth0.com/.well-known/jwks.json",
)?;
registration.stale_while_error = std::time::Duration::from_secs(90);
registry.register(registration).await?;
let jwks = registry.resolve("tenant-a", "auth0", None).await?;
println!("Fetched {} keys.", jwks.keys.len());
// No-op unless the `redis` feature is enabled.
registry.persist_all().await?;
Ok(())
}Use the registry to resolve a kid and build a DecodingKey for jsonwebtoken:
use jsonwebtoken::{Algorithm, DecodingKey, Validation};
use jwks_cache::Registry;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Claims {
sub: String,
exp: usize,
aud: Vec<String>,
}
async fn verify(registry: &Registry, token: &str) -> Result<Claims, Box<dyn std::error::Error>> {
let header = jsonwebtoken::decode_header(token)?;
let kid = header.kid.ok_or("token is missing a kid claim")?;
let jwks = registry.resolve("tenant-a", "auth0", Some(&kid)).await?;
let jwk = jwks.find(&kid).ok_or("no JWKS entry found for kid")?;
let decoding_key = DecodingKey::from_jwk(jwk)?;
let mut validation = Validation::new(header.alg);
validation.set_audience(&["api://default"]);
let token = jsonwebtoken::decode::<Claims>(token, &decoding_key, &validation)?;
Ok(token.claims)
}The optional third argument to Registry::resolve lets you pass the kid up front, enabling cache hits even when providers rotate keys frequently.
Registry keeps tenant/provider state isolated while applying consistent guardrails. The most relevant knobs on IdentityProviderRegistration are:
| Field | Purpose | Default |
|---|---|---|
refresh_early |
Proactive refresh lead time before TTL expiry. | 30s (overridable globally via RegistryBuilder::default_refresh_early) |
stale_while_error |
Serve cached payloads while refreshes fail. | 60s (overridable via default_stale_while_error) |
min_ttl |
Floor applied to upstream cache directives. | 30s |
max_ttl |
Cap applied to upstream TTLs. | 24h |
max_response_bytes |
Maximum JWKS payload size accepted. | 1_048_576 bytes |
negative_cache_ttl |
Optional TTL for failed upstream fetches. | Disabled (0s) |
max_redirects |
Upper bound on HTTP redirects while fetching. | 3 (hard limit 10) |
prefetch_jitter |
Randomised offset applied to refresh scheduling. | 5s |
retry_policy |
Exponential backoff configuration for fetches. | Initial attempt + 2 retries, 250 ms → 2 s backoff, 3 s per attempt, 8 s deadline, full jitter |
pinned_spki |
SHA-256 SPKI fingerprints for TLS pinning. | Empty |
register/unregisterkeep provider state scoped to each tenant.resolveserves cached JWKS payloads with per-tenant metrics tagging.refreshtriggers an immediate background refresh without waiting for TTL expiry.provider_statusandall_statusesexpose lifecycle state, expiry, error counters, hit rates, and the metrics that powerjwks-cache.openapi.yaml.
RegistryBuilder::require_https(true)(default) enforces HTTPS for every registration.- Domain allowlists can be applied globally (
add_allowed_domain) or per registration (allowed_domains). - Provide
pinned_spkivalues (base64 SHA-256) to guard against certificate substitution.
redis: enable Redis-backed snapshots forpersist_allandrestore_from_persistence. When disabled, these methods are cheap no-ops so lifecycle code can stay shared.
- Metrics emitted via the
metricsfacade includejwks_cache_requests_total,jwks_cache_hits_total,jwks_cache_misses_total,jwks_cache_stale_total,jwks_cache_refresh_total,jwks_cache_refresh_errors_total, and thejwks_cache_refresh_duration_secondshistogram. install_default_exporterinstalls the bundled Prometheus recorder (metrics-exporter-prometheus) and exposes aPrometheusHandlefor HTTP servers to serve/metrics.- Every cache operation is instrumented with
tracingspans keyed by tenant and provider identifiers, making it easy to correlate logs, traces, and metrics.
Enable the redis feature to persist JWKS payloads between deploys:
let registry = jwks_cache::Registry::builder()
.require_https(true)
.add_allowed_domain("tenant-a.auth0.com")
.with_redis_client(redis::Client::open("redis://127.0.0.1/")?)
.build();
// During startup:
registry.restore_from_persistence().await?;
// On graceful shutdown:
registry.persist_all().await?;Snapshots store the JWKS body, validators, and expiry metadata, keeping cold starts off identity provider rate limits.
cargo fmtcargo clippy --all-targets --all-featurescargo testcargo test --features redis(integration coverage for Redis persistence)
Integration tests rely on wiremock to exercise HTTP caching behaviour, retries, and stale-while-error semantics.
If you find this project helpful and would like to support its development, you can buy me a coffee!
Your support is greatly appreciated and motivates me to keep improving this project.
- Fiat
- Crypto
- Bitcoin
bc1pedlrf67ss52md29qqkzr2avma6ghyrt4jx9ecp9457qsl75x247sqcp43c
- Ethereum
0x3e25247CfF03F99a7D83b28F207112234feE73a6
- Polkadot
156HGo9setPcU2qhFMVWLkcmtCEGySLwNqa3DaEiYSWtte4Y
- Bitcoin
Thank you for your support!
We would like to extend our heartfelt gratitude to the following projects and contributors:
Grateful for the Rust community and the maintainers of reqwest, http-cache-semantics, metrics, redis, and tracing, whose work makes this cache possible.
- TODO
Licensed under GPL-3.0.