Skip to content
Documentation GitHub
Security

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 fn handlers
  • uuid::Uuid::new_v4() used for bearer tokens (only 122 bits of entropy)
  • All errors map to generic internal_error regardless 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, &params.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 (subtle crate)
  • 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
  • ServerInfo includes name and version for client identification

Warning Signs

  • #[allow(dead_code)] on middleware functions
  • CorsLayer::new().allow_origin(Any) in production code
  • .map_err(|e| ErrorData::internal_error(...) on every handler

References

Was this page helpful?