Keycloak + Rust (Axum): Secure Your Rust Web API

Guilliano Molaire Guilliano Molaire Updated June 5, 2026 11 min read

Last updated: March 2026

Rust is increasingly adopted for backend services where performance, memory safety, and reliability matter. Axum — built on top of Tokio and Tower — has emerged as the most popular Rust web framework for building async HTTP services. When you pair it with Keycloak for single sign-on and identity management, you get a high-performance API backend with enterprise-grade authentication and zero SaaS lock-in.

This guide walks through securing an Axum web API with Keycloak, covering JWKS-based JWT verification, custom extractors for authentication, and role-based middleware. Every code example compiles against current stable Rust and uses production-ready patterns.

Prerequisites

Step 1: Set Up a Keycloak Client

In the Keycloak Admin Console:

  1. Go to Clients > Create client
  2. Set Client ID to rust-axum-api
  3. Set Client type to OpenID Connect
  4. Set Client authentication to Off (public client for SPA frontends) or On (confidential client for service-to-service)
  5. Set Valid redirect URIs to http://localhost:3000/*
  6. Set Web origins to http://localhost:3000

Create Roles

Create two client roles for testing:

  1. Go to your client’s Roles tab
  2. Create api:read and api:admin roles
  3. Assign them to test users under Users > Role mappings > Client roles

For a deeper understanding of role configuration, see our RBAC feature overview or the guide on fine-grained authorization in Keycloak.

Step 2: Project Setup

Create a new Rust project and add the required dependencies.

cargo new keycloak-axum-api
cd keycloak-axum-api

Add the following to your Cargo.toml:

[package]
name = "keycloak-axum-api"
version = "0.1.0"
edition = "2021"

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jsonwebtoken = "9"
reqwest = { version = "0.12", features = ["json"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors"] }
tracing = "0.1"
tracing-subscriber = "0.3"
thiserror = "2"
once_cell = "1"

Key crates:

  • jsonwebtoken — RS256 JWT decoding and validation against JWKS public keys
  • reqwest — fetches the JWKS key set from Keycloak
  • tower-http — CORS middleware for cross-origin requests

Step 3: Configuration

Create a configuration module that reads Keycloak connection details from environment variables:

// src/config.rs
use once_cell::sync::Lazy;

pub struct KeycloakConfig {
    pub realm_url: String,
    pub jwks_url: String,
    pub issuer: String,
    pub audience: String,
}

pub static KEYCLOAK: Lazy<KeycloakConfig> = Lazy::new(|| {
    let base_url = std::env::var("KEYCLOAK_URL")
        .unwrap_or_else(|_| "http://localhost:8080".to_string());
    let realm = std::env::var("KEYCLOAK_REALM")
        .unwrap_or_else(|_| "master".to_string());
    let audience = std::env::var("KEYCLOAK_CLIENT_ID")
        .unwrap_or_else(|_| "rust-axum-api".to_string());

    let realm_url = format!("{}/realms/{}", base_url, realm);

    KeycloakConfig {
        jwks_url: format!(
            "{}/protocol/openid-connect/certs",
            realm_url
        ),
        issuer: realm_url.clone(),
        audience,
        realm_url,
    }
});

You can verify your Keycloak OIDC discovery endpoint at {realm_url}/.well-known/openid-configuration. Our JWT Token Analyzer is useful for inspecting tokens during development.

Step 4: JWKS Key Fetching

Keycloak publishes its public signing keys at the JWKS endpoint. You need to fetch these keys to verify JWT signatures. In production, you should cache the keys and refresh them periodically.

// src/jwks.rs
use jsonwebtoken::jwk::{JwkSet};
use reqwest::Client;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{info, error};

use crate::config::KEYCLOAK;

#[derive(Clone)]
pub struct JwksClient {
    http: Client,
    keys: Arc<RwLock<Option<JwkSet>>>,
}

impl JwksClient {
    pub fn new() -> Self {
        Self {
            http: Client::new(),
            keys: Arc::new(RwLock::new(None)),
        }
    }

    pub async fn fetch_keys(&self) -> Result<JwkSet, reqwest::Error> {
        let jwks: JwkSet = self
            .http
            .get(&KEYCLOAK.jwks_url)
            .send()
            .await?
            .json()
            .await?;

        info!("Fetched {} keys from JWKS endpoint", jwks.keys.len());

        // Cache the keys
        let mut cached = self.keys.write().await;
        *cached = Some(jwks.clone());

        Ok(jwks)
    }

    pub async fn get_keys(&self) -> Result<JwkSet, reqwest::Error> {
        // Return cached keys if available
        {
            let cached = self.keys.read().await;
            if let Some(ref keys) = *cached {
                return Ok(keys.clone());
            }
        }
        // Otherwise fetch fresh keys
        self.fetch_keys().await
    }
}

For production use, add a background task that refreshes keys on a timer (e.g., every 15 minutes) or implement on-demand refresh when a kid is not found in the cached set.

Step 5: JWT Claims and Validation

Define the JWT claims structure matching Keycloak’s token format and build the validation logic:

// src/auth.rs
use axum::{
    extract::FromRequestParts,
    http::{request::Parts, StatusCode},
    response::{IntoResponse, Response},
    Json,
};
use jsonwebtoken::{
    decode, decode_header, Algorithm, DecodingKey, Validation,
    jwk::AlgorithmParameters,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

use crate::jwks::JwksClient;
use crate::config::KEYCLOAK;

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct KeycloakClaims {
    pub sub: String,
    pub iss: String,
    pub aud: serde_json::Value,
    pub exp: u64,
    pub iat: u64,
    pub preferred_username: Option<String>,
    pub email: Option<String>,
    pub email_verified: Option<bool>,
    pub realm_access: Option<RealmAccess>,
    pub resource_access: Option<HashMap<String, ResourceAccess>>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RealmAccess {
    pub roles: Vec<String>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ResourceAccess {
    pub roles: Vec<String>,
}

impl KeycloakClaims {
    /// Check if the user has a specific realm role
    pub fn has_realm_role(&self, role: &str) -> bool {
        self.realm_access
            .as_ref()
            .map(|ra| ra.roles.iter().any(|r| r == role))
            .unwrap_or(false)
    }

    /// Check if the user has a specific client role
    pub fn has_client_role(&self, client: &str, role: &str) -> bool {
        self.resource_access
            .as_ref()
            .and_then(|ra| ra.get(client))
            .map(|ca| ca.roles.iter().any(|r| r == role))
            .unwrap_or(false)
    }
}

#[derive(Debug)]
pub enum AuthError {
    MissingToken,
    InvalidToken(String),
    KeyFetchError(String),
}

impl IntoResponse for AuthError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AuthError::MissingToken => (
                StatusCode::UNAUTHORIZED,
                "Missing or malformed Authorization header".to_string(),
            ),
            AuthError::InvalidToken(msg) => (
                StatusCode::UNAUTHORIZED,
                format!("Invalid token: {}", msg),
            ),
            AuthError::KeyFetchError(msg) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                format!("Failed to fetch signing keys: {}", msg),
            ),
        };

        let body = serde_json::json!({ "error": message });
        (status, Json(body)).into_response()
    }
}

pub async fn validate_token(
    token: &str,
    jwks_client: &JwksClient,
) -> Result<KeycloakClaims, AuthError> {
    // Decode the JWT header to get the key ID (kid)
    let header = decode_header(token)
        .map_err(|e| AuthError::InvalidToken(e.to_string()))?;

    let kid = header.kid.ok_or_else(|| {
        AuthError::InvalidToken("Token missing kid header".to_string())
    })?;

    // Fetch JWKS and find the matching key
    let jwks = jwks_client
        .get_keys()
        .await
        .map_err(|e| AuthError::KeyFetchError(e.to_string()))?;

    let jwk = jwks
        .keys
        .iter()
        .find(|k| k.common.key_id.as_deref() == Some(&kid))
        .ok_or_else(|| {
            AuthError::InvalidToken(format!("No matching key for kid: {}", kid))
        })?;

    // Build the decoding key from the JWK
    let decoding_key = match &jwk.algorithm {
        AlgorithmParameters::RSA(rsa) => {
            DecodingKey::from_rsa_components(&rsa.n, &rsa.e)
                .map_err(|e| AuthError::InvalidToken(e.to_string()))?
        }
        _ => {
            return Err(AuthError::InvalidToken(
                "Unsupported key algorithm".to_string(),
            ));
        }
    };

    // Configure validation
    let mut validation = Validation::new(Algorithm::RS256);
    validation.set_issuer(&[&KEYCLOAK.issuer]);
    // Keycloak tokens may have audience as string or array
    validation.set_audience(&[&KEYCLOAK.audience]);
    validation.validate_aud = false; // Keycloak audience handling varies

    // Decode and validate the token
    let token_data = decode::<KeycloakClaims>(token, &decoding_key, &validation)
        .map_err(|e| AuthError::InvalidToken(e.to_string()))?;

    Ok(token_data.claims)
}

Step 6: Axum Extractor for Authentication

Axum extractors let you inject authenticated user data directly into handler function signatures. This is one of Axum’s strongest patterns — it keeps handlers clean and composable.

// src/extractors.rs
use axum::{
    extract::FromRequestParts,
    http::request::Parts,
};
use crate::auth::{AuthError, KeycloakClaims, validate_token};
use crate::jwks::JwksClient;

/// Extractor that validates the JWT and provides claims
pub struct AuthenticatedUser(pub KeycloakClaims);

impl<S> FromRequestParts<S> for AuthenticatedUser
where
    S: Send + Sync,
{
    type Rejection = AuthError;

    async fn from_request_parts(
        parts: &mut Parts,
        _state: &S,
    ) -> Result<Self, Self::Rejection> {
        // Extract the Authorization header
        let auth_header = parts
            .headers
            .get("Authorization")
            .and_then(|v| v.to_str().ok())
            .ok_or(AuthError::MissingToken)?;

        let token = auth_header
            .strip_prefix("Bearer ")
            .ok_or(AuthError::MissingToken)?;

        // Get the JWKS client from extensions
        let jwks_client = parts
            .extensions
            .get::<JwksClient>()
            .ok_or_else(|| {
                AuthError::KeyFetchError(
                    "JWKS client not configured".to_string(),
                )
            })?
            .clone();

        let claims = validate_token(token, &jwks_client).await?;
        Ok(AuthenticatedUser(claims))
    }
}

/// Extractor that requires a specific role
pub struct RequireRole<const ROLE: &'static str>(pub KeycloakClaims);

Step 7: Role-Based Middleware

For more granular access control, create middleware that checks roles before the handler runs:

// src/middleware.rs
use axum::{
    extract::Request,
    http::StatusCode,
    middleware::Next,
    response::{IntoResponse, Response},
    Json,
};
use crate::auth::{AuthError, KeycloakClaims, validate_token};
use crate::jwks::JwksClient;

/// Middleware that requires authentication
pub async fn require_auth(
    mut request: Request,
    next: Next,
) -> Result<Response, AuthError> {
    let auth_header = request
        .headers()
        .get("Authorization")
        .and_then(|v| v.to_str().ok())
        .ok_or(AuthError::MissingToken)?;

    let token = auth_header
        .strip_prefix("Bearer ")
        .ok_or(AuthError::MissingToken)?;

    let jwks_client = request
        .extensions()
        .get::<JwksClient>()
        .ok_or_else(|| {
            AuthError::KeyFetchError("JWKS client not configured".to_string())
        })?
        .clone();

    let claims = validate_token(token, &jwks_client).await?;

    // Insert claims into request extensions for handlers
    request.extensions_mut().insert(claims);

    Ok(next.run(request).await)
}

/// Middleware factory that requires a specific client role
pub fn require_role(
    client_id: &'static str,
    role: &'static str,
) -> impl Fn(Request, Next) -> std::pin::Pin<
    Box<dyn std::future::Future<Output = Result<Response, Response>> + Send>,
> + Clone {
    move |mut request: Request, next: Next| {
        Box::pin(async move {
            let auth_header = request
                .headers()
                .get("Authorization")
                .and_then(|v| v.to_str().ok())
                .ok_or_else(|| {
                    AuthError::MissingToken.into_response()
                })?;

            let token = auth_header
                .strip_prefix("Bearer ")
                .ok_or_else(|| AuthError::MissingToken.into_response())?;

            let jwks_client = request
                .extensions()
                .get::<JwksClient>()
                .ok_or_else(|| {
                    AuthError::KeyFetchError(
                        "JWKS client not configured".to_string(),
                    )
                    .into_response()
                })?
                .clone();

            let claims = validate_token(token, &jwks_client)
                .await
                .map_err(|e| e.into_response())?;

            if !claims.has_client_role(client_id, role) {
                return Err((
                    StatusCode::FORBIDDEN,
                    Json(serde_json::json!({
                        "error": format!(
                            "Missing required role: {}",
                            role
                        )
                    })),
                )
                    .into_response());
            }

            request.extensions_mut().insert(claims);
            Ok(next.run(request).await)
        })
    }
}

Step 8: Protected Route Handlers

Now bring everything together with route handlers:

// src/main.rs
mod auth;
mod config;
mod extractors;
mod jwks;
mod middleware;

use axum::{
    extract::Extension,
    routing::get,
    Json, Router,
};
use tower_http::cors::{CorsLayer, Any};
use tracing_subscriber;

use crate::auth::KeycloakClaims;
use crate::extractors::AuthenticatedUser;
use crate::jwks::JwksClient;

#[tokio::main]
async fn main() {
    tracing_subscriber::init();

    let jwks_client = JwksClient::new();

    // Pre-fetch keys at startup
    if let Err(e) = jwks_client.fetch_keys().await {
        tracing::error!("Failed to fetch JWKS keys at startup: {}", e);
    }

    let cors = CorsLayer::new()
        .allow_origin(Any)
        .allow_headers(Any)
        .allow_methods(Any);

    let app = Router::new()
        // Public routes
        .route("/health", get(health))
        // Authenticated routes
        .route("/api/me", get(get_current_user))
        .route("/api/protected", get(protected_resource))
        // Admin routes using middleware
        .route("/api/admin/users", get(admin_users))
        .layer(Extension(jwks_client))
        .layer(cors);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    tracing::info!("Server running on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

/// Public health check
async fn health() -> Json<serde_json::Value> {
    Json(serde_json::json!({ "status": "ok" }))
}

/// Returns the current user's profile from the JWT
async fn get_current_user(
    AuthenticatedUser(claims): AuthenticatedUser,
) -> Json<serde_json::Value> {
    Json(serde_json::json!({
        "sub": claims.sub,
        "username": claims.preferred_username,
        "email": claims.email,
        "email_verified": claims.email_verified,
        "realm_roles": claims.realm_access
            .map(|ra| ra.roles)
            .unwrap_or_default(),
    }))
}

/// Protected resource that requires any valid token
async fn protected_resource(
    AuthenticatedUser(claims): AuthenticatedUser,
) -> Json<serde_json::Value> {
    Json(serde_json::json!({
        "message": format!(
            "Hello, {}!",
            claims.preferred_username.unwrap_or("unknown".to_string())
        ),
        "data": {
            "items": ["item1", "item2", "item3"]
        }
    }))
}

/// Admin endpoint --- requires api:admin role
async fn admin_users(
    AuthenticatedUser(claims): AuthenticatedUser,
) -> Result<Json<serde_json::Value>, axum::response::Response> {
    use axum::http::StatusCode;
    use axum::response::IntoResponse;

    if !claims.has_client_role("rust-axum-api", "api:admin") {
        return Err((
            StatusCode::FORBIDDEN,
            Json(serde_json::json!({
                "error": "Admin role required"
            })),
        )
            .into_response());
    }

    Ok(Json(serde_json::json!({
        "users": [
            { "id": "1", "name": "Alice" },
            { "id": "2", "name": "Bob" },
        ]
    })))
}

Step 9: Testing the API

Start Keycloak (our Docker Compose Generator can create a ready-to-use docker-compose.yml) and then run the API:

export KEYCLOAK_URL=http://localhost:8080
export KEYCLOAK_REALM=myrealm
export KEYCLOAK_CLIENT_ID=rust-axum-api
cargo run

Get an access token from Keycloak:

TOKEN=$(curl -s -X POST 
  "http://localhost:8080/realms/myrealm/protocol/openid-connect/token" 
  -H "Content-Type: application/x-www-form-urlencoded" 
  -d "grant_type=password" 
  -d "client_id=rust-axum-api" 
  -d "username=testuser" 
  -d "password=testpass" 
  | jq -r '.access_token')

You can paste the token into our JWT Token Analyzer to inspect the claims, then test the protected endpoints:

# Public endpoint
curl http://localhost:3000/health

# Authenticated endpoint
curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/me

# Protected resource
curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/protected

# Admin endpoint (requires api:admin role)
curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/admin/users

Production Considerations

JWKS Key Rotation

In production, Keycloak may rotate its signing keys. Your API needs to handle this gracefully:

// In your validate_token function, add key refresh on miss:
let jwk = match jwks.keys.iter().find(|k| {
    k.common.key_id.as_deref() == Some(&kid)
}) {
    Some(key) => key,
    None => {
        // Key not found --- force refresh and retry
        let refreshed = jwks_client
            .fetch_keys()
            .await
            .map_err(|e| AuthError::KeyFetchError(e.to_string()))?;
        refreshed
            .keys
            .iter()
            .find(|k| k.common.key_id.as_deref() == Some(&kid))
            .ok_or_else(|| {
                AuthError::InvalidToken(
                    format!("No key found for kid: {}", kid),
                )
            })?
    }
};

CORS Configuration

For production, replace the permissive CORS policy with explicit origins:

use tower_http::cors::{CorsLayer, AllowOrigin};
use axum::http::{HeaderValue, Method};

let cors = CorsLayer::new()
    .allow_origin(AllowOrigin::exact(
        "https://yourapp.com".parse::<HeaderValue>().unwrap(),
    ))
    .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
    .allow_headers(Any);

For CORS troubleshooting with Keycloak, see configuring CORS with your Keycloak OIDC client.

Session Management

If your API also serves an SPA frontend, consider pairing JWT validation with session management on the Keycloak side. This gives you centralized session control — including the ability to revoke sessions across services.

Monitoring and Audit Logs

Every authentication attempt generates events in Keycloak. Enable audit logging to track login patterns, failed attempts, and token usage. Skycloak’s Insights dashboard provides real-time visibility into these events without needing to query Keycloak directly.

Further Reading

Wrapping Up

Rust and Axum give you a type-safe, high-performance foundation for API development. Combined with Keycloak’s OIDC implementation, you get authentication that is both secure and fast. The extractor pattern keeps auth logic out of your business logic, and the jsonwebtoken crate handles all the cryptographic verification you need.

If you want to skip the infrastructure setup and focus on your application code, Skycloak provides fully managed Keycloak instances with built-in security features, guaranteed uptime SLAs, and a control plane for managing your clusters. Check our pricing to see how it compares to self-hosting.

Tired of running Keycloak yourself?

Skycloak runs real upstream Keycloak for you with a 99.99% SLA. No fork, no lock-in, just managed Keycloak that stays patched and on call so you don't have to.

Guilliano Molaire
Written by
Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Start Free Trial Talk to Sales
© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman