Skip to main content

Protect your Axum API with RBAC and JWT validation

This guide will help you implement authorization to secure your Axum APIs using Role-based access control (RBAC) and JSON Web Tokens (JWTs) issued by Logto.

Before you start

Your client applications need to obtain access tokens from Logto. If you haven't set up client integration yet, check out our Quick starts for React, Vue, Angular, or other client frameworks, or see our Machine-to-machine guide for server-to-server access.

This guide focuses on the server-side validation of those tokens in your Axum application.

A figure showing the focus of this guide

What you will learn

  • JWT validation: Learn to validate access tokens and extract authentication information
  • Middleware implementation: Create reusable middleware for API protection
  • Permission models: Understand and implement different authorization patterns:
    • Global API resources for application-wide endpoints
    • Organization permissions for tenant-specific feature control
    • Organization-level API resources for multi-tenant data access
  • RBAC integration: Enforce role-based permissions and scopes in your API endpoints

Prerequisites

  • Latest stable version of Rust installed
  • Basic understanding of Axum and web API development
  • A Logto application configured (see Quick starts if needed)

Permission models overview

Before implementing protection, choose the permission model that fits your application architecture. This aligns with Logto's three main authorization scenarios:

Global API resources RBAC
  • Use case: Protect API resources shared across your entire application (not organization-specific)
  • Token type: Access token with global audience
  • Examples: Public APIs, core product services, admin endpoints
  • Best for: SaaS products with APIs used by all customers, microservices without tenant isolation
  • Learn more: Protect global API resources

💡 Choose your model before proceeding - the implementation will reference your chosen approach throughout this guide.

Quick preparation steps

Configure Logto resources & permissions

  1. Create API resource: Go to Console → API resources and register your API (e.g., https://api.yourapp.com)
  2. Define permissions: Add scopes like read:products, write:orders – see Define API resources with permissions
  3. Create global roles: Go to Console → Roles and create roles that include your API permissions – see Configure global roles
  4. Assign roles: Assign roles to users or M2M applications that need API access
New to RBAC?:

Start with our Role-based access control guide for step-by-step setup instructions.

Update your client application

Request appropriate scopes in your client:

The process usually involves updating your client configuration to include one or more of the following:

  • scope parameter in OAuth flows
  • resource parameter for API resource access
  • organization_id for organization context
Before you code:

Make sure the user or M2M app you are testing has been assigned proper roles or organization roles that include the necessary permissions for your API.

Initialize your API project

To initialize a new Axum project, create a directory and set up the basic structure:

cargo new your-api-name
cd your-api-name

Add Axum dependencies to your Cargo.toml:

Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1.0", features = ["full"] }
tower = "0.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

Create a basic Axum application:

src/main.rs
use axum::{
response::Json,
routing::get,
Router,
};
use serde_json::{json, Value};

#[tokio::main]
async fn main() {
let app = Router::new()
.route("/", get(hello_handler));

let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}

async fn hello_handler() -> Json<Value> {
Json(json!({ "message": "Hello from Axum" }))
}

Start the development server:

cargo run
note:

Refer to the Axum documentation for more details on how to set up routes, middleware, and other features.

Initialize constants and utilities

Define necessary constants and utilities in your code to handle token extraction and validation. A valid request must include an Authorization header in the form Bearer <access_token>.

lib.rs
use serde::{Deserialize, Serialize};
use std::fmt;

pub const JWKS_URI: &str = "https://your-tenant.logto.app/oidc/jwks";
pub const ISSUER: &str = "https://your-tenant.logto.app/oidc";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthInfo {
pub sub: String,
pub client_id: Option<String>,
pub organization_id: Option<String>,
pub scopes: Vec<String>,
pub audience: Vec<String>,
}

impl AuthInfo {
pub fn new(
sub: String,
client_id: Option<String>,
organization_id: Option<String>,
scopes: Vec<String>,
audience: Vec<String>,
) -> Self {
Self {
sub,
client_id,
organization_id,
scopes,
audience,
}
}
}

#[derive(Debug)]
pub struct AuthorizationError {
pub message: String,
pub status_code: u16,
}

impl AuthorizationError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
status_code: 403,
}
}

pub fn with_status(message: impl Into<String>, status_code: u16) -> Self {
Self {
message: message.into(),
status_code,
}
}
}

impl fmt::Display for AuthorizationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}

impl std::error::Error for AuthorizationError {}

pub fn extract_bearer_token(authorization: Option<&str>) -> Result<&str, AuthorizationError> {
let auth_header = authorization.ok_or_else(|| {
AuthorizationError::with_status("Authorization header is missing", 401)
})?;

if !auth_header.starts_with("Bearer ") {
return Err(AuthorizationError::with_status(
"Authorization header must start with \"Bearer \"",
401,
));
}

Ok(&auth_header[7..]) // Remove 'Bearer ' prefix
}

Retrieve info about your Logto tenant

You’ll need the following values to validate Logto-issued tokens:

  • JSON Web Key Set (JWKS) URI: The URL to Logto’s public keys, used to verify JWT signatures.
  • Issuer: The expected issuer value (Logto’s OIDC URL).

First, find your Logto tenant’s endpoint. You can find it in various places:

  • In the Logto Console, under SettingsDomains.
  • In any application settings where you configured in Logto, SettingsEndpoints & Credentials.

Fetch from OpenID Connect discovery endpoint

These values can be retrieved from Logto’s OpenID Connect discovery endpoint:

https://<your-logto-endpoint>/oidc/.well-known/openid-configuration

Here’s an example response (other fields omitted for brevity):

{
"jwks_uri": "https://your-tenant.logto.app/oidc/jwks",
"issuer": "https://your-tenant.logto.app/oidc"
}

Since Logto doesn't allow customizing the JWKS URI or issuer, you can hardcode these values in your code. However, this is not recommended for production applications as it may increase maintenance overhead if some configuration changes in the future.

  • JWKS URI: https://<your-logto-endpoint>/oidc/jwks
  • Issuer: https://<your-logto-endpoint>/oidc

Validate the token and permissions

After extracting the token and fetching the OIDC config, validate the following:

  • Signature: JWT must be valid and signed by Logto (via JWKS).
  • Issuer: Must match your Logto tenant’s issuer.
  • Audience: Must match the API’s resource indicator registered in Logto, or the organization context if applicable.
  • Expiration: Token must not be expired.
  • Permissions (scopes): Token must include required scopes for your API/action. Scopes are space-separated strings in the scope claim.
  • Organization context: If protecting organization-level API resources, validate the organization_id claim.

See JSON Web Token to learn more about JWT structure and claims.

What to check for each permission model

The claims and validation rules differ by permission model:

  • Audience claim (aud): API resource indicator
  • Organization claim (organization_id): Not present
  • Scopes (permissions) to check (scope): API resource permissions

For non-API organization permissions, the organization context is represented by the aud claim (e.g., urn:logto:organization:abc123). The organization_id claim is only present for organization-level API resource tokens.

tip:

Always validate both permissions (scopes) and context (audience, organization) for secure multi-tenant APIs.

Add the validation logic

We use jsonwebtoken to validate JWTs. Add the required dependencies to your Cargo.toml:

Cargo.toml
[dependencies]
jsonwebtoken = "9.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
reqwest = { version = "0.11", features = ["json"] }
tokio = { version = "1.0", features = ["full"] }

First, add these shared utilities to handle JWT validation:

jwt_validator.rs
use crate::{AuthInfo, AuthorizationError, ISSUER, JWKS_URI};
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation};
use serde_json::Value;
use std::collections::HashMap;

pub struct JwtValidator {
jwks: HashMap<String, DecodingKey>,
}

impl JwtValidator {
pub async fn new() -> Result<Self, AuthorizationError> {
let jwks = Self::fetch_jwks().await?;
Ok(Self { jwks })
}

async fn fetch_jwks() -> Result<HashMap<String, DecodingKey>, AuthorizationError> {
let response = reqwest::get(JWKS_URI).await.map_err(|e| {
AuthorizationError::with_status(format!("Failed to fetch JWKS: {}", e), 401)
})?;

let jwks: Value = response.json().await.map_err(|e| {
AuthorizationError::with_status(format!("Failed to parse JWKS: {}", e), 401)
})?;

let mut keys = HashMap::new();

if let Some(keys_array) = jwks["keys"].as_array() {
for key in keys_array {
if let (Some(kid), Some(kty), Some(n), Some(e)) = (
key["kid"].as_str(),
key["kty"].as_str(),
key["n"].as_str(),
key["e"].as_str(),
) {
if kty == "RSA" {
if let Ok(decoding_key) = DecodingKey::from_rsa_components(n, e) {
keys.insert(kid.to_string(), decoding_key);
}
}
}
}
}

if keys.is_empty() {
return Err(AuthorizationError::with_status("No valid keys found in JWKS", 401));
}

Ok(keys)
}

pub fn validate_jwt(&self, token: &str) -> Result<AuthInfo, AuthorizationError> {
let header = decode_header(token).map_err(|e| {
AuthorizationError::with_status(format!("Invalid token header: {}", e), 401)
})?;

let kid = header.kid.ok_or_else(|| {
AuthorizationError::with_status("Token missing kid claim", 401)
})?;

let key = self.jwks.get(&kid).ok_or_else(|| {
AuthorizationError::with_status("Unknown key ID", 401)
})?;

let mut validation = Validation::new(Algorithm::RS256);
validation.set_issuer(&[ISSUER]);
validation.validate_aud = false; // We'll verify audience manually

let token_data = decode::<Value>(token, key, &validation).map_err(|e| {
AuthorizationError::with_status(format!("Invalid token: {}", e), 401)
})?;

let claims = token_data.claims;
self.verify_payload(&claims)?;

Ok(self.create_auth_info(claims))
}

fn verify_payload(&self, claims: &Value) -> Result<(), AuthorizationError> {
// Implement your verification logic here based on permission model
// This will be shown in the permission models section below
Ok(())
}

fn create_auth_info(&self, claims: Value) -> AuthInfo {
let scopes = claims["scope"]
.as_str()
.map(|s| s.split(' ').map(|s| s.to_string()).collect())
.unwrap_or_default();

let audience = match &claims["aud"] {
Value::Array(arr) => arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect(),
Value::String(s) => vec![s.clone()],
_ => vec![],
};

AuthInfo::new(
claims["sub"].as_str().unwrap_or_default().to_string(),
claims["client_id"].as_str().map(|s| s.to_string()),
claims["organization_id"].as_str().map(|s| s.to_string()),
scopes,
audience,
)
}
}

Then, implement the middleware to verify the access token:

middleware.rs
use crate::{AuthInfo, AuthorizationError, extract_bearer_token};
use crate::jwt_validator::JwtValidator;
use axum::{
extract::Request,
http::{HeaderMap, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Extension, Json,
};
use serde_json::json;
use std::sync::Arc;

pub async fn jwt_middleware(
Extension(validator): Extension<Arc<JwtValidator>>,
headers: HeaderMap,
mut request: Request,
next: Next,
) -> Result<Response, AuthorizationError> {
let authorization = headers
.get("authorization")
.and_then(|h| h.to_str().ok());

let token = extract_bearer_token(authorization)?;
let auth_info = validator.validate_jwt(token)?;

// Store auth info in request extensions for generic use
request.extensions_mut().insert(auth_info);

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

impl IntoResponse for AuthorizationError {
fn into_response(self) -> Response {
let status = StatusCode::from_u16(self.status_code).unwrap_or(StatusCode::FORBIDDEN);
(status, Json(json!({ "error": self.message }))).into_response()
}
}

According to your permission model, implement the appropriate verification logic in JwtValidator:

jwt_validator.rs
fn verify_payload(&self, claims: &Value) -> Result<(), AuthorizationError> {
// Check audience claim matches your API resource indicator
let audiences = match &claims["aud"] {
Value::Array(arr) => arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>(),
Value::String(s) => vec![s.as_str()],
_ => vec![],
};

if !audiences.contains(&"https://your-api-resource-indicator") {
return Err(AuthorizationError::new("Invalid audience"));
}

// Check required scopes for global API resources
let required_scopes = vec!["api:read", "api:write"]; // Replace with your actual required scopes
let scopes = claims["scope"]
.as_str()
.map(|s| s.split(' ').collect::<Vec<_>>())
.unwrap_or_default();

for required_scope in &required_scopes {
if !scopes.contains(required_scope) {
return Err(AuthorizationError::new("Insufficient scope"));
}
}

Ok(())
}

Apply the middleware to your API

Now, apply the middleware to your protected API routes.

main.rs
use axum::{
extract::Extension,
http::StatusCode,
middleware,
response::Json,
routing::get,
Router,
};
use serde_json::{json, Value};
use std::sync::Arc;
use tower_http::cors::CorsLayer;

mod lib;
mod jwt_validator;
mod middleware as jwt_middleware;

use lib::AuthInfo;
use jwt_validator::JwtValidator;

#[tokio::main]
async fn main() {
let validator = Arc::new(JwtValidator::new().await.expect("Failed to initialize JWT validator"));

let app = Router::new()
.route("/api/protected", get(protected_handler))
.layer(middleware::from_fn(jwt_middleware::jwt_middleware))
.layer(Extension(validator))
.layer(CorsLayer::permissive());

let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}

async fn protected_handler(Extension(auth): Extension<AuthInfo>) -> Json<Value> {
// Access auth information directly from Extension
Json(json!({ "auth": auth }))
}

Test your protected API

Get access tokens

From your client application: If you've set up a client integration, your app can obtain tokens automatically. Extract the access token and use it in API requests.

For testing with curl/Postman:

  1. User tokens: Use your client app's developer tools to copy the access token from localStorage or the network tab

  2. Machine-to-machine tokens: Use the client credentials flow. Here's a non-normative example using curl:

    curl -X POST https://your-tenant.logto.app/oidc/token \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "grant_type=client_credentials" \
    -d "client_id=your-m2m-client-id" \
    -d "client_secret=your-m2m-client-secret" \
    -d "resource=https://your-api-resource-indicator" \
    -d "scope=api:read api:write"

    You may need to adjust the resource and scope parameters based on your API resource and permissions; an organization_id parameter may also be required if your API is organization-scoped.

tip:

Need to inspect the token contents? Use our JWT decoder to decode and verify your JWTs.

Test protected endpoints

Valid token request
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
http://localhost:3000/api/protected

Expected response:

{
"auth": {
"sub": "user123",
"clientId": "app456",
"organizationId": "org789",
"scopes": ["api:read", "api:write"],
"audience": ["https://your-api-resource-indicator"]
}
}
Missing token
curl http://localhost:3000/api/protected

Expected response (401):

{
"error": "Authorization header is missing"
}
Invalid token
curl -H "Authorization: Bearer invalid-token" \
http://localhost:3000/api/protected

Expected response (401):

{
"error": "Invalid token"
}

Permission model-specific testing

Test scenarios for APIs protected with global scopes:

  • Valid scopes: Test with tokens that include your required API scopes (e.g., api:read, api:write)
  • Missing scopes: Expect 403 Forbidden when token lacks required scopes
  • Wrong audience: Expect 403 Forbidden when audience does not match the API resource
# Token with missing scopes - expect 403
curl -H "Authorization: Bearer token-without-required-scopes" \
http://localhost:3000/api/protected

Further reading

RBAC in practice: Implementing secure authorization for your application

Build a multi-tenant SaaS application: A complete guide from design to implementation