Webhook Signature Verification in Rust With HMAC Checks
Verify webhook signatures in Rust: read the raw body, compute HMAC-SHA256, compare in constant time, check the timestamp against replay and respond fast with a queue.

A webhook endpoint is a public URL that anyone can post to. The signature is the only proof that a request came from the provider, so verifying it correctly is the whole security of the endpoint. Everything compiles on Rust 1.90 with edition 2024, axum 0.8, hmac 0.12, sha2 0.10 and hex 0.4. The header names and signing format vary by provider; this example uses a common pattern (t=timestamp,v1=signature), so read your provider's documentation for the exact one.
The scheme
The provider sends a header such as:
X-Signature: t=1700000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdand signs the string "{t}.{raw_body}" with HMAC-SHA256 using the shared secret. You recompute it and compare.
Verification function
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug)]
pub enum VerifyError {
Header,
Stale,
Signature,
}
pub fn verify(secret: &[u8], header: &str, body: &[u8], tolerance_secs: u64) -> Result<(), VerifyError> {
let mut timestamp = None;
let mut signatures = Vec::new();
for part in header.split(',') {
match part.split_once('=') {
Some(("t", v)) => timestamp = v.parse::<u64>().ok(),
Some(("v1", v)) => signatures.push(v),
_ => {}
}
}
let t = timestamp.ok_or(VerifyError::Header)?;
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
if now.abs_diff(t) > tolerance_secs {
return Err(VerifyError::Stale);
}
for sig in signatures {
let Ok(expected) = hex::decode(sig) else { continue };
let mut mac = HmacSha256::new_from_slice(secret).expect("hmac accepts any key length");
mac.update(t.to_string().as_bytes());
mac.update(b".");
mac.update(body);
if mac.verify_slice(&expected).is_ok() {
return Ok(());
}
}
Err(VerifyError::Signature)
}verify_slice compares the MAC in constant time. Several v1 entries can appear during secret rotation, so the loop accepts any valid one. The timestamp check (commonly five minutes) stops an attacker from replaying a captured request later. Hex decoding errors are skipped, not reported, so malformed input and wrong signatures look the same.
The Axum handler
use axum::{body::Bytes, extract::State, http::{HeaderMap, StatusCode}};
#[derive(Clone)]
pub struct AppState {
pub webhook_secret: Vec<u8>,
}
pub async fn webhook(State(state): State<AppState>, headers: HeaderMap, body: Bytes) -> StatusCode {
let Some(sig) = headers.get("x-signature").and_then(|v| v.to_str().ok()) else {
return StatusCode::UNAUTHORIZED;
};
if verify(&state.webhook_secret, sig, &body, 300).is_err() {
return StatusCode::UNAUTHORIZED;
}
let event: serde_json::Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(_) => return StatusCode::BAD_REQUEST,
};
// enqueue `event` for background processing, keyed by its id
let _ = event;
StatusCode::OK
}Bytes as the extractor gives the untouched body. Verify first; parse after. Axum's default 2 MB body limit applies, which is sensible for webhooks; set a tighter explicit limit if payloads are small (limits). Return the same 401 for every verification failure so the response does not tell an attacker which part was wrong.
After verification
- **Acknowledge fast, process later.** Return
200as soon as the event is queued; slow handlers cause timeouts and retries. - **Be idempotent.** Providers deliver at least once. Record the event id in a table with a unique constraint and skip repeats (idempotency keys).
- **Do not trust the payload's claims blindly.** For sensitive actions (a payment status), fetch the object from the provider's API using the id in the event.
- **Do not log the secret or the full signature header** (logging without leaking secrets).
Rotation and storage
Keep the secret in your secret store, not the repository (secrets management). To rotate, add the new secret, accept both for a window, then remove the old one. Test the verifier with fixed vectors from the provider's documentation and with a modified body, a stale timestamp and a missing header. The constant-time comparison principle is explained in constant time comparison.


