Skip to content

Middleware

FORGE generates a layered middleware pipeline built on Axum's tower integration. Every incoming request passes through a series of middleware functions that handle cross-cutting concerns -- authentication, authorization, request tracing, and CORS -- before reaching your handler. Middleware is applied at different scopes depending on whether a route is public, authenticated, or admin-only.

Middleware Pipeline

Incoming Request


┌──────────────────────┐
│   TraceLayer         │   HTTP request/response logging
├──────────────────────┤
│   CORS               │   Cross-origin request validation
├──────────────────────┤
│   Request ID         │   Assigns X-Request-Id header (UUID)
├──────────────────────┤
│   Auth Middleware     │   JWT token extraction and validation
├──────────────────────┤
│   RBAC Middleware     │   Permission and role verification
├──────────────────────┤
│   Handler            │   Your route handler function
└──────────────────────┘

Not every middleware runs on every request. The pipeline is configured so that public routes skip authentication and RBAC, while admin routes enforce both.

Auth Middleware

The auth middleware extracts a JWT token from the Authorization header, validates it, and injects an AuthUser struct into the request extensions. Downstream handlers and middleware can then access the authenticated user without re-parsing the token.

How It Works

rust
use axum::{extract::{Request, State}, middleware::Next, response::Response};
use crate::error::AppError;
use crate::utils::jwt::{self, TokenType};
use crate::AppState;

pub async fn auth_middleware(
    State(state): State<AppState>,
    mut request: Request,
    next: Next,
) -> Result<Response, AppError> {
    // 1. Extract Bearer token from Authorization header
    let token = extract_token(&request)?;

    // 2. Validate token signature and expiration
    let token_data = jwt::validate_token(&token, &state.config.auth)?;

    // 3. Ensure it's an access token (not a refresh token)
    if token_data.claims.token_type != TokenType::Access {
        return Err(AppError::InvalidToken);
    }

    // 4. Build AuthUser from validated claims
    let auth_user = AuthUser {
        user_id: token_data.claims.sub,
        email: token_data.claims.email,
        token_jti: token_data.claims.jti,
    };

    // 5. Store in request extensions for downstream access
    request.extensions_mut().insert(auth_user);

    // 6. Continue to the next middleware or handler
    Ok(next.run(request).await)
}

The AuthUser Struct

The AuthUser struct is the identity object available to every authenticated handler:

rust
#[derive(Debug, Clone)]
pub struct AuthUser {
    /// User ID extracted from the JWT subject claim
    pub user_id: Uuid,
    /// User email from the token
    pub email: String,
    /// Token ID for blacklisting support
    pub token_jti: Uuid,
}

Using AuthUser in Handlers

AuthUser implements Axum's FromRequestParts trait, so you can use it as a handler parameter directly:

rust
use crate::middleware::AuthUser;

pub async fn get_profile(
    auth_user: AuthUser,
    State(state): State<AppState>,
) -> Result<Json<ApiResponse<ProfileResponse>>, AppError> {
    let profile = ProfileService::get_by_id(&state.db, auth_user.user_id).await?;
    Ok(Json(ApiResponse::success(profile)))
}

If the request does not have an AuthUser in its extensions (because the auth middleware did not run or the token was invalid), the extractor returns a 401 Unauthorized error automatically.

Optional Authentication

Some routes behave differently for authenticated vs. anonymous users. Use optional_auth_middleware for these cases:

rust
pub async fn optional_auth_middleware(
    State(state): State<AppState>,
    mut request: Request,
    next: Next,
) -> Response {
    // Attempts token validation, but continues regardless
    if let Ok(token) = extract_token(&request) {
        if let Ok(token_data) = jwt::validate_token(&token, &state.config.auth) {
            if token_data.claims.token_type == TokenType::Access {
                let auth_user = AuthUser { /* ... */ };
                request.extensions_mut().insert(auth_user);
            }
        }
    }

    // Always continues -- never returns an error
    next.run(request).await
}

TIP

Use optional_auth_middleware for public endpoints like menu listings where authenticated users might see additional items (e.g., menu items with visibility: "auth").

RBAC Middleware

The RBAC middleware checks whether the authenticated user has the required permissions or roles before allowing access. FORGE provides six middleware factory functions that cover every common authorization pattern.

Permission Check

The most common pattern -- verify the user has a single permission:

rust
use axum::{Router, routing::get, middleware};
use crate::middleware::require_permission;

fn user_routes(state: AppState) -> Router<AppState> {
    Router::new()
        .route(
            "/",
            get(list_users)
                .layer(middleware::from_fn_with_state(
                    state.clone(),
                    require_permission("users.view"),
                )),
        )
        .route(
            "/",
            post(create_user)
                .layer(middleware::from_fn_with_state(
                    state.clone(),
                    require_permission("users.create"),
                )),
        )
}

All Middleware Factories

FunctionCheckUse Case
require_permission("perm")User has specific permissionMost routes
require_any_permission(&["a", "b"])User has at least oneView OR edit access
require_all_permissions(&["a", "b"])User has every permissionCombined access
require_role("role")User has specific roleRole-gated features
require_any_role(&["a", "b"])User has at least one roleMultiple admin tiers
require_all_roles(&["a", "b"])User has every roleCompound role checks

Example: Multiple Permissions

rust
use crate::middleware::require_any_permission;

// User needs either contents.edit OR contents.create
.route(
    "/contents/draft",
    post(save_draft)
        .layer(middleware::from_fn_with_state(
            state.clone(),
            require_any_permission(&["contents.edit", "contents.create"]),
        )),
)

Super Admin Bypass

All RBAC middleware automatically bypasses permission checks for super admins. A user is considered a super admin if they have the super_admin role or the wildcard * permission:

rust
impl UserPermissions {
    pub fn is_super_admin(&self) -> bool {
        self.roles.contains("super_admin") || self.permissions.contains("*")
    }
}

WARNING

Super admin bypass happens silently in the middleware. This means super admins can access every route regardless of individual permission settings. Use the super_admin role sparingly and only for root-level administrators.

Permission Caching

Loading permissions from the database on every request would be expensive. FORGE includes an in-memory permission cache with a configurable TTL (default: 5 minutes):

rust
use crate::middleware::PermissionCache;
use std::time::Duration;

// Created during application startup
let permission_cache = PermissionCache::new(Duration::from_secs(300));

The cache is stored in AppState and used automatically by all RBAC middleware. Key operations:

rust
// Cache is checked automatically before hitting the database
let perms = get_user_permissions(&state.db, &state.permission_cache, user_id).await?;

// Invalidate a specific user's cache (e.g., after role change)
state.permission_cache.invalidate(user_id).await;

// Invalidate all cached permissions (e.g., after permission seeder runs)
state.permission_cache.invalidate_all().await;

// Clean up expired entries (call periodically)
state.permission_cache.cleanup_expired().await;

TIP

When you update a user's roles through the admin API, invalidate their permission cache so the change takes effect immediately rather than waiting for the TTL to expire.

UserPermissions Extractor

For fine-grained permission checks within a handler (beyond what route-level middleware provides), use the UserPermissionsExtractor:

rust
use crate::middleware::UserPermissionsExtractor;

pub async fn complex_handler(
    auth_user: AuthUser,
    perms: UserPermissionsExtractor,
    State(state): State<AppState>,
) -> Result<impl IntoResponse, AppError> {
    // Check specific permission in handler logic
    if perms.has_permission("users.create") {
        // Allow bulk creation
    }

    // Check role
    if perms.has_role("editor") {
        // Show editor-specific options
    }

    // Check multiple permissions
    if perms.has_all_permissions(&["contents.edit", "media.upload"]) {
        // Allow content with media
    }

    Ok(Json(ApiResponse::success(result)))
}

API Key Middleware

FORGE also generates API key authentication for programmatic access. API keys are sent via the X-API-Key header and follow the format forge_sk_...:

rust
pub async fn api_key_middleware(
    State(state): State<AppState>,
    mut request: Request,
    next: Next,
) -> Result<Response, AppError> {
    let api_key = extract_api_key(&request)?;  // From X-API-Key header
    let key_row = service.validate_key(&api_key).await?;

    // Track usage asynchronously (non-blocking)
    tokio::spawn(async move {
        service.update_usage(key_id, client_ip).await;
    });

    request.extensions_mut().insert(AuthApiKey::from(key_row));
    Ok(next.run(request).await)
}

API keys have their own permission system, separate from user roles:

rust
use crate::middleware::require_api_key_permission;

// Protect an external endpoint with API key + permission
.route(
    "/external/users",
    get(list_users_external)
        .layer(middleware::from_fn(require_api_key_permission("users.view")))
        .layer(middleware::from_fn_with_state(state.clone(), api_key_middleware)),
)

WARNING

API key middleware and auth middleware are independent. A route should use one or the other, not both. For routes that accept either authentication method, use optional_auth_middleware combined with optional_api_key_middleware.

Request Tracing

FORGE uses Tower's TraceLayer for HTTP request and response logging. This is applied globally in the router setup:

rust
use tower_http::trace::TraceLayer;

fn create_router(state: AppState) -> Router {
    Router::new()
        .nest("/api/v1", routes::api_routes(state.clone()))
        .route("/health", get(health_check))
        .layer(TraceLayer::new_for_http())  // Logs every request
        .with_state(state)
}

The trace layer logs request method, path, status code, and latency using the tracing crate. Configure log verbosity via the RUST_LOG environment variable:

bash
# Show API debug logs and tower_http debug logs
RUST_LOG=myapp_api=debug,tower_http=debug cargo run

# Production: only warnings and errors
RUST_LOG=myapp_api=warn cargo run

CORS Configuration

CORS is handled at the reverse proxy level (Caddy) in FORGE's standard deployment. However, if you run the API without a proxy, you can add tower_http's CORS layer:

rust
use tower_http::cors::{CorsLayer, Any};
use axum::http::{HeaderName, Method};

fn create_router(state: AppState) -> Router {
    let cors = CorsLayer::new()
        .allow_origin(Any)
        .allow_methods([
            Method::GET,
            Method::POST,
            Method::PUT,
            Method::PATCH,
            Method::DELETE,
        ])
        .allow_headers([
            HeaderName::from_static("content-type"),
            HeaderName::from_static("authorization"),
            HeaderName::from_static("x-api-key"),
        ]);

    Router::new()
        .nest("/api/v1", routes::api_routes(state.clone()))
        .layer(cors)
        .with_state(state)
}

TIP

In the default FORGE deployment with Caddy, CORS headers are set at the proxy level. Adding a CorsLayer to the Axum server would cause duplicate headers. Only add it if you are running the API directly without a reverse proxy.

Applying Middleware to Routes

Axum provides two ways to apply middleware, and FORGE uses both depending on the scope:

Group-Level Middleware

Applied to an entire group of routes using .layer():

rust
pub fn admin_routes(state: AppState) -> Router<AppState> {
    Router::new()
        .nest("/users", user_routes(state.clone()))
        .nest("/roles", role_routes(state.clone()))
        .nest("/contents", content_routes(state.clone()))
        // Auth middleware applies to ALL admin routes
        .layer(middleware::from_fn_with_state(state, auth_middleware))
}

Route-Level Middleware

Applied to individual routes using .layer() on the route:

rust
fn content_routes(state: AppState) -> Router<AppState> {
    Router::new()
        .route(
            "/",
            get(list_contents)
                .layer(middleware::from_fn_with_state(
                    state.clone(),
                    require_permission("contents.view"),
                )),
        )
        .route(
            "/",
            post(create_content)
                .layer(middleware::from_fn_with_state(
                    state.clone(),
                    require_permission("contents.create"),
                )),
        )
}

WARNING

Axum middleware executes in reverse order of how it is added. The last .layer() call runs first. This means auth middleware (added last to the admin group) runs before RBAC middleware (added per-route), which is the correct order -- you must authenticate before checking permissions.

Custom Middleware

To add your own middleware, follow this pattern:

rust
use axum::{extract::Request, middleware::Next, response::Response};

pub async fn my_custom_middleware(
    // Optional: inject application state
    State(state): State<AppState>,
    // The incoming request
    request: Request,
    // The next middleware or handler in the chain
    next: Next,
) -> Result<Response, AppError> {
    // Before the handler: inspect/modify request
    let start = std::time::Instant::now();

    // Pass the request to the next middleware
    let response = next.run(request).await;

    // After the handler: inspect/modify response
    let duration = start.elapsed();
    tracing::info!("Request took {:?}", duration);

    Ok(response)
}

Register it in your router:

rust
use axum::middleware;

let app = Router::new()
    .route("/api/data", get(handler))
    .layer(middleware::from_fn_with_state(state, my_custom_middleware));

See Also

Released under the MIT License.