Replay Attack in Axum with Mutual Tls
Replay Attack in Axum with Mutual Tls — how this specific combination creates or exposes the vulnerability
A replay attack occurs when an adversary intercepts a valid request and retransmits it to produce an unauthorized effect. In Axum, enabling Mutual TLS (mTLS) ensures that both client and server present certificates, which authenticates the endpoints. However, mTLS by itself does not prevent replay because the TLS session provides encryption and peer identity but does not inherently guarantee that a given request is unique over time. A captured TLS-encrypted request can be replayed within the validity window of the client certificate, and the server will accept it as legitimate because the certificate-based authentication succeeds.
In Axum, this risk is particularly relevant when idempotency controls (such as nonces or timestamps) are not enforced at the application layer. For example, an HTTP POST that transfers funds or changes state could be replayed within seconds while the mTLS channel remains valid. The attack surface is not limited to missing application controls; it is also influenced by the TLS configuration and how Axum applications consume requests. If the server does not validate request uniqueness and relies only on mTLS for security, replay becomes a viable threat. This is compounded when TLS session resumption or session tickets are used, potentially allowing replay across resumed sessions even when application-layer tokens are absent.
Consider an Axum API that accepts JSON payloads for a transfer without an idempotency key or timestamp validation. An attacker who can observe a single mTLS-authenticated request can replay the exact same TCP/HTTP layer data to the endpoint. Because mTLS only authenticates who is speaking and not whether the message itself is fresh, the server processes the duplicate request as valid. The combination of mTLS and missing replay protections therefore creates a scenario where authentication is strong but authorization and integrity of the transaction are weak.
Mutual Tls-Specific Remediation in Axum — concrete code fixes
To mitigate replay in Axum with mTLS, you must combine robust mTLS configuration with application-level replay protections such as idempotency keys, timestamps, or nonces. Below are concrete Axum examples that show how to enforce mTLS and integrate replay-safe patterns.
1) Configure mTLS in Axum via hyper::server::Builder
Use Rustls to load server and client CA certificates, requiring client authentication. This ensures only trusted clients can reach your Axum routes, but remember to also add replay defenses at the handler level.
use axum::Server; use hyper::{service::service_fn, Body, Request, Response}; use std::net::SocketAddr; use tokio_rustls::rustls::{self, server::AllowAnyAuthenticatedClient, NoClientAuth, RootCertStore}; use tokio_rustls::TlsAcceptor; async fn build_mtls_acceptor() -> TlsAcceptor { // Load server certificate and private key let cert_chain = rustls_pemfile::certs(&mut std::io::BufReader::new(std::fs::File::open("server-cert.pem").unwrap())) .collect::, _>>() .unwrap(); let mut keys = rustls_pemfile::pkcs8_private_keys(&mut std::io::BufReader::new(std::fs::File::open("server-key.pem").unwrap())) .collect:: , _>>() .unwrap(); let cert_res = rustls::Certificate(cert_chain[0].clone()); let key = rustls::PrivateKey(keys.remove(0)); // Configure client CA store for mTLS let mut client_ca = RootCertStore::empty(); client_ca.add_parsable_certificates(&rustls_pemfile::certs(&mut std::io::BufReader::new(std::fs::File::open("client-ca.pem").unwrap())) .collect:: , _>>() .unwrap()); let config = rustls::ServerConfig::builder() .with_safe_defaults() .with_no_client_auth() // We'll override with AllowAnyAuthenticatedClient .with_client_cert_verifier(AllowAnyAuthenticatedClient::new(client_ca)); let config = std::sync::Arc::new(config); TlsAcceptor::from(Arc::new(config)) } #[tokio::main] async fn main() { let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); let tls_acceptor = build_mtls_acceptor().await; let service = service_fn(|req: Request<Body>| async move { // TODO: add replay protection here Ok::<_, hyper::Error>(Response::new(Body::from("OK"))) }); let server = Server::builder(hyper::service::make_service_fn(|_conn| async { Ok::<_, hyper::Error>(service) })) .serve_with_incoming(tls_acceptor.incoming(tokio::net::TcpListener::bind(addr).unwrap())); server.await.unwrap(); } 2) Add replay-safe idempotency in Axum handlers
Combine mTLS with an idempotency key stored in a fast cache (e.g., Redis). The handler checks the key before applying side effects. This prevents duplicate processing even if a request is replayed over a valid mTLS session.
use axum::{routing::post, Router}; use std::sync::Arc; use redis::AsyncCommands; struct AppState { redis_client: redis::Client, } async fn handle_transfer( State(state): State>, Json(payload): Json<TransferPayload>, ) -> Result<impl IntoResponse, (StatusCode, String)> { let idempotency_key = payload.idempotency_key.clone() .ok_or_else(|| (StatusCode::BAD_REQUEST, "Missing idempotency key".to_string()))?; let mut conn = state.redis_client.get_async_connection().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let exists: bool = conn.exists(&idempotency_key).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; if exists { return Ok((StatusCode::OK, "Already processed").into_response()); } // Perform transfer logic here conn.set_ex(&idempotency_key, "processed", 3600).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(StatusCode::CREATED.into_response()) } #[derive(serde::Deserialize)] struct TransferPayload { idempotency_key: Option<String>, amount: u64, to: String, } fn app() -> Router { let state = Arc::new(AppState { redis_client: redis::Client::open("redis://127.0.0.1/").unwrap(), }); Router::new() .route("/transfer", post(handle_transfer)) .with_state(state) } 3) Enforce timestamp/nonce validation alongside mTLS
For non-idempotent endpoints, require a short-lived timestamp or nonce in headers, validated server-side. This ensures that replayed mTLS-authenticated requests with stale timestamps are rejected.
use axum::{routing::post, Extension}; use std::time::{SystemTime, UNIX_EPOCH}; async fn validate_timestamp( Extension(cfg): Extension<AppConfig>, headers: HeaderMap, ) -> Result<(), (StatusCode, String)> { let ts_header = headers.get("X-Request-Timestamp") .ok_or((StatusCode::BAD_REQUEST, "Missing timestamp header".to_string()))?; let ts_str = ts_header.to_str().map_err(|_| (StatusCode::BAD_REQUEST, "Invalid timestamp header".to_string()))?; let ts: u64 = ts_str.parse().map_err(|_| (StatusCode::BAD_REQUEST, "Invalid timestamp format".to_string()))?; let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); if now.saturating_sub(ts) > cfg.clock_skew_seconds { return Err((StatusCode::REQUEST_TIMEOUT, "Timestamp too old".to_string())); } Ok(()) } struct AppConfig { clock_skew_seconds: u64, } async fn handle_post_with_time( Extension(cfg): Extension<AppConfig>, headers: HeaderMap, Json(payload): Json<PostPayload>, ) -> Result<impl IntoResponse, (StatusCode, String)> { validate_timestamp(Extension(cfg), headers).await?; // Process the request knowing replay within the time window is unlikely Ok(StatusCode::CREATED.into_response()) }