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

  1. Algorithm whitelist (most important): only RS256/ES256; reject none and unexpected algorithms.
  2. Verify iss + aud: otherwise tokens from one app can be accepted by another.
  3. JWKS must be cached + refreshed in the background: otherwise every request hits the IdP, and key rotation causes a mass outage (thundering herd).
  4. Validate the state parameter in the authorization-code flow: prevents CSRF; don’t validate only code.
  5. Don’t stuff the access token into a client-editable cookie: use an opaque session id + server-side claim storage.
  6. Keep clock-skew leeway small: 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.