rust
64 lines · 8 steps
Building a replay-guard extractor in Axum
A custom FromRequestParts extractor rejects stale or replayed requests using a timestamp and a shared per-client seen map.
Explained by
highlit
1use axum::{
2 extract::FromRequestParts,
3 http::{request::Parts, HeaderMap, StatusCode},
4};
5use std::{
6 collections::HashMap,
7 sync::{Arc, Mutex},
8 time::{Duration, SystemTime, UNIX_EPOCH},
9};
10
11#[derive(Clone, Default)]
12pub struct ReplayGuard {
13 last_seen: Arc<Mutex<HashMap<String, u64>>>,
14}
15
16pub struct FreshRequest {
17 pub client_id: String,
18 pub timestamp_ms: u64,
19}
20
21const MAX_SKEW: Duration = Duration::from_secs(30);
22
23impl<S> FromRequestParts<S> for FreshRequest
24where
25 ReplayGuard: FromRef<S>,
26 S: Send + Sync,
27{
28 type Rejection = (StatusCode, &'static str);
29
30 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
31 let guard = ReplayGuard::from_ref(state);
32 let client_id = header(&parts.headers, "x-client-id")?.to_owned();
33 let timestamp_ms: u64 = header(&parts.headers, "x-request-timestamp")?
34 .parse()
35 .map_err(|_| (StatusCode::BAD_REQUEST, "malformed X-Request-Timestamp"))?;
36
37 let now = SystemTime::now()
38 .duration_since(UNIX_EPOCH)
39 .unwrap()
40 .as_millis() as u64;
41 if timestamp_ms.abs_diff(now) > MAX_SKEW.as_millis() as u64 {
42 return Err((StatusCode::UNAUTHORIZED, "timestamp outside allowed skew"));
43 }
44
45 let mut seen = guard.last_seen.lock().unwrap();
46 match seen.get(&client_id) {
47 Some(&prev) if timestamp_ms <= prev => {
48 return Err((StatusCode::CONFLICT, "stale or replayed X-Request-Timestamp"));
49 }
50 _ => {
51 seen.insert(client_id.clone(), timestamp_ms);
52 }
53 }
54
55 Ok(FreshRequest { client_id, timestamp_ms })
56 }
57}
58
59fn header<'a>(headers: &'a HeaderMap, name: &'static str) -> Result<&'a str, (StatusCode, &'static str)> {
60 headers
61 .get(name)
62 .and_then(|v| v.to_str().ok())
63 .ok_or((StatusCode::BAD_REQUEST, "missing required header"))
64}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Implementing FromRequestParts lets you fold authentication and validation into a handler argument that runs before your logic.
- 2Bounding a timestamp by a maximum clock skew and rejecting non-increasing values together defend against replay attacks.
- 3Sharing mutable state across requests via Arc<Mutex<..>> and FromRef keeps per-client bookkeeping accessible inside extractors.
Related explainers
rust
use serde::Deserialize; #[derive(Debug, Deserialize)] #[serde(untagged)]
Parsing flexible JSON shapes with serde
deserialization
enums
json
Intermediate
6 steps
rust
use std::cmp::{Ordering, Reverse}; use std::collections::BinaryHeap; use std::fs::File; use std::io::{self, BufRead, BufReader, BufWriter, Lines, Write};
K-way merge of sorted logs in Rust
binary-heap
k-way-merge
streaming-io
Intermediate
8 steps
rust
use axum::{ extract::{Path, State}, response::sse::{Event, KeepAlive, Sse}, };
Streaming import progress with SSE in Axum
server-sent-events
streams
watch-channel
Advanced
7 steps
rust
use std::f64::consts::PI; #[derive(Debug, Clone, Copy)] pub struct LatLng {
Building geographic bounding boxes in Rust
geospatial
value-types
option
Intermediate
7 steps
go
func (w *Watcher) resetDebounce(d time.Duration) { if !w.timer.Stop() { select { case <-w.timer.C:
Debouncing a stream of events in Go
debounce
timers
channels
Advanced
7 steps
rust
use chrono::{Duration, NaiveDate}; #[derive(Debug)] pub struct DateRange {
Parsing and iterating date ranges in Rust
error-handling
iterators
parsing
Intermediate
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/building-a-replay-guard-extractor-in-axum-explained-rust-ab13/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.