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
- Rust 1.75+ with Cargo
- A running Keycloak instance (version 22+). Use our Docker Compose Generator for a quick local setup or try managed Keycloak hosting.
- Basic familiarity with Rust async programming and OAuth 2.0 / OIDC concepts
Step 1: Set Up a Keycloak Client
In the Keycloak Admin Console:
- Go to Clients > Create client
- Set Client ID to
rust-axum-api - Set Client type to
OpenID Connect - Set Client authentication to Off (public client for SPA frontends) or On (confidential client for service-to-service)
- Set Valid redirect URIs to
http://localhost:3000/* - Set Web origins to
http://localhost:3000
Create Roles
Create two client roles for testing:
- Go to your client’s Roles tab
- Create
api:readandapi:adminroles - 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 keysreqwest— fetches the JWKS key set from Keycloaktower-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
- Keycloak OpenID Connect Documentation
- jsonwebtoken crate documentation
- Axum extractors documentation
- Securing Node.js microservices with Keycloak — similar patterns in Node.js
- Keycloak Go API authentication — the Go equivalent of this tutorial
- Token validation for APIs — framework-agnostic token validation strategies
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.