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
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:
#[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:
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:
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:
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
| Function | Check | Use Case |
|---|---|---|
require_permission("perm") | User has specific permission | Most routes |
require_any_permission(&["a", "b"]) | User has at least one | View OR edit access |
require_all_permissions(&["a", "b"]) | User has every permission | Combined access |
require_role("role") | User has specific role | Role-gated features |
require_any_role(&["a", "b"]) | User has at least one role | Multiple admin tiers |
require_all_roles(&["a", "b"]) | User has every role | Compound role checks |
Example: Multiple Permissions
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:
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):
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:
// 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:
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_...:
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:
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:
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:
# 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 runCORS 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:
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():
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:
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:
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:
use axum::middleware;
let app = Router::new()
.route("/api/data", get(handler))
.layer(middleware::from_fn_with_state(state, my_custom_middleware));See Also
- Authentication -- JWT token generation and validation
- Authorization -- RBAC system and permission management
- Routes -- How routes are organized and registered
- Error Handling -- Error responses from middleware failures