Replay Attack in Axum with Dynamodb
Replay Attack in Axum with Dynamodb — how this specific combination creates or exposes the vulnerability
A replay attack occurs when an attacker intercepts a valid request and retransmits it to reproduce the intended effect. In an Axum service that uses Amazon DynamoDB as its persistence layer, the combination of HTTP semantics, idempotent API calls to DynamoDB, and missing request-level safeguards can make replay attacks practical.
Consider an endpoint that transfers funds by writing to a DynamoDB table keyed by account ID. If the request does not carry a unique, one-time nonce or timestamp, an attacker can capture a legitimate HTTP POST with a JSON body such as {"from": "alice", "to": "bob", "amount": 100, "timestamp": 1700000000} and replay it. Because DynamoDB operations like PutItem or UpdateItem are idempotent based on the primary key, repeating the same request may result in the same state change if the application logic does not enforce uniqueness. For example, an UpdateItem with an ADD on a balance field will apply the increment on each replay, effectively doubling the amount if the application does not check whether the operation was already executed.
The risk surface expands when using conditional writes in DynamoDB (e.g., ConditionExpression to enforce uniqueness). If the condition checks a client-supplied value without server-side nonce tracking, an attacker can craft a replay that satisfies the condition once but, when repeated, fails the condition and returns a ConditionalCheckFailedException. However, if the application treats that exception as benign or retries without re-evaluating intent, it can lead to inconsistent state or expose timing differences that aid further exploitation. Unauthenticated LLM endpoint detection is one of the twelve checks middleBrick runs, and while it targets LLM endpoints, it highlights how exposed endpoints without proper authentication controls increase the attack surface.
Additionally, logging or error messages that include the full request payload or conditional check details can aid an attacker in refining replays. MiddleBrick scans for such exposures across the unauthenticated attack surface, including data exposure and input validation checks. Even without authentication, an attacker can probe the API to understand behavior, identify idempotent paths, and confirm whether nonces or timestamps are enforced. The interplay between Axum’s routing and handler logic and DynamoDB’s write semantics means that missing anti-replay controls at the application layer directly enable replay attacks.
Dynamodb-Specific Remediation in Axum — concrete code fixes
To mitigate replay attacks in an Axum service using DynamoDB, introduce server-side idempotency keys and strict conditional checks. Each client request should carry a unique idempotency token (e.g., a UUIDv4) that the server stores alongside the outcome of the operation. Before performing a state-changing write, the server checks whether the token has been seen and, if so, returns the original result instead of applying the operation again.
In Axum, you can model this with a handler that extracts an idempotency key from a header, queries DynamoDB for a record of that key, and proceeds only if it is absent. Use a dedicated DynamoDB table for idempotency tokens with a primary key composed of idempotency_key (partition key) and a short TTL to allow cleanup. The following example uses the aws-sdk-rust crate with DynamoDB to implement this pattern in an Axum handler.
use aws_sdk_dynamodb::Client as DdbClient;
use axum::{async_trait, extract::Extension, routing::post, Router};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use uuid::Uuid;
#[derive(Debug, Deserialize, Serialize)]
struct TransferRequest {
from: String,
to: String,
amount: i64,
}
#[derive(Debug, Serialize)]
struct TransferResponse {
status: String,
idempotency_key: String,
}
async fn transfer_handler(
Extension(db): Extension,
payload: Result, axum::http::StatusCode>,
) -> Result, (axum::http::StatusCode, String)> {
let payload = payload.map_err(|_| (axum::http::StatusCode::BAD_REQUEST, "invalid body"))?;
let idempotency_key = axum::http::HeaderValue::from_str(
&axum::extract::Request::headers().get("Idempotency-Key").ok_or("missing header")?,
)
.map_err(|_| (axum::http::StatusCode::BAD_REQUEST, "invalid idempotency key header"))?;
let key = format!("idempotency#{}", idempotency_key.to_str().unwrap_or_default());
// Check for existing idempotency record
let get_out = db
.get_item()
.table_name("api_idempotency")
.key("idempotency_key", aws_sdk_dynamodb::types::AttributeValue::S(key.clone()))
.send()
.await
.map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if let Some(item) = get_out.item {
let status: String = item.get("status")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let prev_response: TransferResponse = serde_json::from_str(
item.get("response")
.and_then(|v| v.as_s())
.unwrap_or("{}"),
)
.unwrap_or_else(|_| TransferResponse { status: "unknown".into(), idempotency_key: key });
return Ok(axum::response::Json(prev_response));
}
// Process the transfer with a conditional write to enforce uniqueness at the data level
let account_pk = format!("account#{}", payload.from);
let outcome = db
.update_item()
.table_name("accounts")
.key("pk", aws_sdk_dynamodb::types::AttributeValue::S(account_pk.clone()))
.update_expression("SET balance = if_not_exists(balance, :zero) - :amt, last_tx = :now")
.condition_expression("attribute_exists(pk)")
.expression_attribute_values(":zero", aws_sdk_dynamodb::types::AttributeValue::N("0".into()))
.expression_attribute_values(":amt", aws_sdk_dynamodb::types::AttributeValue::N(payload.amount.to_string()))
.expression_attribute_values(":now", aws_sdk_dynamodb::types::AttributeValue::S(Utc::now().to_rfc3339()))
.send()
.await
.map_err(|e| (axum::http::StatusCode::CONFLICT, e.to_string()))?;
// Store idempotency record to reject replays
let response = TransferResponse {
status: "ok".into(),
idempotency_key: key.clone(),
};
db.put_item()
.table_name("api_idempotency")
.item("idempotency_key", aws_sdk_dynamodb::types::AttributeValue::S(key))
.item("status", aws_sdk_dynamodb::types::AttributeValue::S("completed".into()))
.item("response", aws_sdk_dynamodb::types::AttributeValue::S(serde_json::to_string(&response).unwrap()))
.send()
.await
.map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(axum::response::Json(response))
}
#[tokio::main]
async fn main() {
let config = aws_config::load_from_env().await;
let db = DdbClient::new(&config);
let app = Router::new()
.route("/transfer", post(transfer_handler))
.layer(Extension(db));
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
}
This approach ensures that repeated submissions with the same idempotency key return the original response without performing additional writes. Combined with server-side validation of timestamps and strict conditional checks on DynamoDB, replay attacks are effectively mitigated while preserving the idempotent nature of operations.