梦兽编程
AI_SUITE

Stop Hand-Rolling JWT Validation: Add Production-Grade Auth to Your Rust API with OIDC + Axum

Rolling your own JWT validation, 8 out of 10 people miss something: no alg=none guard, no aud/iss checks, hardcoded keys, no clock-skew tolerance. This guide builds an OIDC Bearer-token validation middleware in Axum — JWKS fetching, algorithm whitelist, iss/aud checks, clock-skew tolerance — plus the simplest way to plug into Keycloak or Auth0.

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.

Frequently Asked Questions

What is the difference between OIDC and OAuth2?

OAuth2 solves authorization (letting a third party access resources on your behalf); OIDC layers identity on top: besides an access_token it also issues an id_token (a JWT) carrying identity claims like sub and email. In short, use OIDC when you need to log someone in and know who they are.

What is the alg=none attack, and why must algorithms be whitelisted?

A JWT's header carries an alg field. If the server blindly trusts it, an attacker can set alg to none and forge a signature that passes validation. The correct approach is to accept only whitelisted algorithms (e.g. RS256) and never pick the verifier based on the token's own alg.

Why should JWKS be cached?

JWKS is the set of public keys an IdP rotates periodically. Fetching it on every request hammers the IdP, adds latency, and risks a mass failure (thundering herd) when keys rotate. In production you cache in memory and refresh in the background, keyed by kid.

This site already has Axum tutorials — how does this fit?

This article focuses on the authentication layer and assumes you can set up Axum routes. Pair it with the repo's Axum from-zero-to-hero and graceful-shutdown guides.