Axum Localhost Service Hardening Checklist
Axum Localhost Service Hardening Checklist
Problem
When building an in-process HTTP service (e.g., MCP server inside a Tauri app), it’s easy to scaffold auth middleware, CORS, and timeouts but forget to actually wire them. A code review of the Inklings MCP server found 9 security/correctness issues despite having all the pieces implemented.
Symptoms:
- Auth middleware exists in source but
#[allow(dead_code)]hints it’s unused CorsLayer::new().allow_origin(Any)on a localhost-only service- Sync database calls inside
async fnhandlers uuid::Uuid::new_v4()used for bearer tokens (only 122 bits of entropy)- All errors map to generic
internal_errorregardless of cause
Root Cause
Incremental development: each piece (auth, CORS, timeout) was added in isolation without a security review of the
assembled whole. The middleware was implemented but never added as a .layer() on the router.
Solution
1. Wire Auth Middleware (Critical)
Axum layers execute bottom-to-top. The Extension holding the token must be added AFTER (below) the middleware that
reads it:
let app = axum::Router::new() .nest_service("/mcp", service) // Auth middleware reads BearerToken from extensions .layer(axum::middleware::from_fn(auth::require_bearer_token)) // Extension must be BELOW middleware that uses it (bottom-to-top) .layer(axum::Extension(bearer)) // CORS and timeout are outermost .layer(cors_layer) .layer(timeout_layer);Key insight: If you swap the Extension and middleware layers, the middleware won’t find the token in extensions and will return 500.
2. Constant-Time Token Comparison
Prevent timing side-channel attacks on bearer tokens:
use subtle::ConstantTimeEq;
let token_bytes = token.as_bytes();let expected_bytes = expected.0.as_bytes();if token_bytes.len() != expected_bytes.len() || token_bytes.ct_eq(expected_bytes).unwrap_u8() != 1{ return Err(StatusCode::UNAUTHORIZED);}The length check is intentionally non-constant-time (leaking length is acceptable; leaking content is not).
3. CSPRNG Token Generation (256-bit)
Replace UUIDv4 (122 bits entropy) with 256-bit CSPRNG hex:
use rand::Fill;
let mut bytes = [0u8; 32];rand::Fill::fill(&mut bytes, &mut rand::rng());let token: String = bytes.iter().map(|b| format!("{b:02x}")).collect();4. Restrict CORS to Localhost
For a service only accessed from 127.0.0.1:
tower_http::cors::CorsLayer::new() .allow_origin("http://127.0.0.1".parse::<HeaderValue>().unwrap()) .allow_methods([Method::POST]) .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])5. Add Request Timeout
Prevent hung requests from exhausting the thread pool:
tower_http::timeout::TimeoutLayer::with_status_code( std::time::Duration::from_secs(60), StatusCode::REQUEST_TIMEOUT,)6. Wrap Sync IO in spawn_blocking
All synchronous SQLite calls in async handlers must use spawn_blocking:
async fn search( &self, Parameters(params): Parameters<SearchParams>,) -> Result<CallToolResult, ErrorData> { let state = self.state.clone(); tokio::task::spawn_blocking(move || { tools::discovery::search(&state, ¶ms.query) }) .await .map_err(|e| ErrorData::internal_error(e.to_string(), None))? .map_err(|e| mcp_error_to_error_data(&e))}Note: Clone state and move owned params into the closure.
7. Semantic Error Codes
Map application errors to appropriate JSON-RPC codes:
fn mcp_error_to_error_data(err: &McpError) -> ErrorData { match err { McpError::NotFound(msg) => ErrorData::new(ErrorCode::RESOURCE_NOT_FOUND, msg.clone(), None), McpError::Validation(msg) => ErrorData::new(ErrorCode::INVALID_PARAMS, msg.clone(), None), McpError::Permission(_) => ErrorData::new(ErrorCode::INVALID_REQUEST, "Permission denied".into(), None), McpError::Internal(msg) => ErrorData::internal_error(msg.clone(), None), // ... }}Security note: Permission errors should return generic “Permission denied” externally while logging specifics server-side.
8. Don’t Leak Tokens in Status Endpoints
Status polling endpoints should return has_token: bool, not the actual token. Provide a separate explicit endpoint for
token retrieval.
Prevention
Checklist for New HTTP Services
- Auth middleware
.layer()actually wired on router - Layer ordering: Extension below middleware that reads it
- Token comparison is constant-time (
subtlecrate) - Token is CSPRNG (>=256 bits), not UUIDv4
- CORS restricted to expected origins/methods/headers
- Request timeout configured
- Sync IO wrapped in
spawn_blocking - Error responses use semantic codes, not all
internal_error - Sensitive data (tokens) not returned in polling endpoints
-
ServerInfoincludes name and version for client identification
Warning Signs
#[allow(dead_code)]on middleware functionsCorsLayer::new().allow_origin(Any)in production code.map_err(|e| ErrorData::internal_error(...)on every handler
References
- INK-250 code review findings P1-01, P1-04, P2-01, P2-08, P2-13, P2-16, P3-03, P3-09, P3-11
- axum middleware ordering docs
- subtle crate
TipTap Extension Option Mutation + No-Op Dispatch for NodeView Re-Render Next
Path Canonicalization for Safe Cleanup Operations
Was this page helpful?
Thanks for your feedback!