Roll your own JWT validation and 8 out of 10 people leave something out: no alg=none guard, no aud/iss checks, hardcoded keys, no clock-skew tolerance… any one of those gaps makes authentication a fig leaf.
OIDC (OpenID Connect) standardizes this validation: it specifies that the IdP (Keycloak / Auth0 / Okta) publishes public keys through a JWKS endpoint on a schedule, and your service only verifies signatures with those keys — key rotation becomes fully automatic. This guide builds a production-grade Bearer-token validation middleware in Axum.
Dependencies
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
jsonwebtoken = "9"
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
Step 1: Define Claims and config
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct Claims {
pub sub: String, // unique user id
pub exp: usize, // expiration
pub iss: String, // issuer
pub aud: String, // audience (your client_id)
}
pub struct AuthConfig {
pub issuer: String, // e.g. https://your.keycloak.com/realms/demo
pub audience: String, // your client_id
pub jwks_url: String, // e.g. the issuer above + /.well-known/jwks.json
}
Step 2: Fetch the public key from JWKS
JWKS is a set of RSA public keys, each tagged with a kid (key id). At verification time you pick the key matching the kid in the token header:
use jsonwebtoken::DecodingKey;
#[derive(Deserialize)]
struct Jwks {
keys: Vec<Jwk>,
}
#[derive(Deserialize)]
struct Jwk {
kid: String,
n: String, // RSA modulus (Base64URL)
e: String, // RSA exponent
}
// Simplified: re-fetches every time. In production, ADD caching + background refresh!
async fn fetch_jwk(config: &AuthConfig, kid: &str) -> Result<DecodingKey, AuthError> {
let jwks: Jwks = reqwest::get(&config.jwks_url).await?.json().await?;
let key = jwks
.keys
.into_iter()
.find(|k| k.kid == kid)
.ok_or(AuthError::KeyNotFound)?;
// Build the verification key from RSA components — never hardcode a key in code
Ok(DecodingKey::from_rsa_components(&key.n, &key.e)?)
}
Step 3: Validate the token (every security check included)
use jsonwebtoken::{decode, decode_header, Algorithm, Validation};
pub async fn validate_token(token: &str, config: &AuthConfig) -> Result<Claims, AuthError> {
let header = decode_header(token)?;
// ① Most critical: whitelist alg, never trust the token's own alg (blocks alg=none)
if header.alg != Algorithm::RS256 {
return Err(AuthError::UnsupportedAlgorithm);
}
let kid = header.kid.ok_or(AuthError::MissingKid)?;
let key = fetch_jwk(config, &kid).await?;
let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[&config.issuer]); // ② verify issuer
validation.set_audience(&[&config.audience]); // ③ verify audience
validation.validate_exp = true;
validation.leeway = 30; // ④ tolerate 30s clock skew
let data = decode::<Claims>(token, &key, &validation)?;
Ok(data.claims)
}
①②③④ are all mandatory — verifying the signature but not iss/aud means a token signed for another app can still enter your service.
Step 4: Protect routes with Axum middleware
use axum::{
extract::{Request, Extension},
middleware::{self, Next},
response::Response,
routing::get,
Router,
};
use http::StatusCode;
pub async fn require_auth(mut req: Request, next: Next) -> Result<Response, StatusCode> {
// Extract the Bearer token
let token = req
.headers()
.get(http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or(StatusCode::UNAUTHORIZED)?;
// In a real project, pull AuthConfig from app state and reuse a cached JWKS client
let config = get_config(&req);
let claims = validate_token(token, &config)
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
// Stash claims in request extensions; handlers can extract them directly
req.extensions_mut().insert(claims);
Ok(next.run(req).await)
}
async fn protected(Extension(claims): Extension<Claims>) -> String {
format!("Hello, user {}!", claims.sub)
}
// Routes: apply the auth layer only to /protected
let app = Router::new()
.route("/protected", get(protected))
.route_layer(middleware::from_fn(require_auth));
Want it even easier? One-line integration
If you don’t want to maintain JWKS caching and refresh yourself, use a community crate:
async-oidc-jwt-validator = "0.1"
let config = OidcConfig::new_with_discovery(
"https://your.keycloak.com/realms/demo".into(),
"your-client-id".into(),
)
.await?;
let validator = OidcValidator::new(config);
let claims = validator.validate::<Claims>(token).await?;
It auto-discovers the JWKS endpoint, caches public keys in memory, and enforces issuer/audience/signature/expiration — supporting Keycloak, Auth0, Okta, and Google.
6 production pitfalls checklist
- Algorithm whitelist (most important): only RS256/ES256; reject
noneand unexpected algorithms. - Verify
iss+aud: otherwise tokens from one app can be accepted by another. - JWKS must be cached + refreshed in the background: otherwise every request hits the IdP, and key rotation causes a mass outage (thundering herd).
- Validate the
stateparameter in the authorization-code flow: prevents CSRF; don’t validate onlycode. - Don’t stuff the access token into a client-editable cookie: use an opaque session id + server-side claim storage.
- Keep clock-skew
leewaysmall: 30 seconds is enough; too large effectively loosens expiration checks.
Hand authentication to standardized OIDC + JWKS, and you only have to hold the two iron rules — whitelist algorithms and verify issuer/audience — while key rotation and revocation are left to the IdP. That’s the peace of mind a Rust backend deserves.