rust
58 lines · 8 steps
Verifying Stripe webhook signatures in Axum
An Axum handler that authenticates Stripe webhooks with an HMAC-SHA256 signature and a timestamp freshness check.
Explained by
highlit
1use axum::{
2 body::Bytes,
3 extract::State,
4 http::{HeaderMap, StatusCode},
5 response::IntoResponse,
6};
7use hmac::{Hmac, Mac};
8use sha2::Sha256;
9use std::time::{SystemTime, UNIX_EPOCH};
10use subtle::ConstantTimeEq;
11
12type HmacSha256 = Hmac<Sha256>;
13
14#[derive(Clone)]
15pub struct WebhookState {
16 pub signing_secret: String,
17}
18
19pub async fn handle_stripe_webhook(
20 State(state): State<WebhookState>,
21 headers: HeaderMap,
22 body: Bytes,
23) -> impl IntoResponse {
24 let sig_header = match headers.get("Stripe-Signature").and_then(|v| v.to_str().ok()) {
25 Some(h) => h,
26 None => return (StatusCode::BAD_REQUEST, "missing signature").into_response(),
27 };
28
29 let mut timestamp = None;
30 let mut expected = None;
31 for part in sig_header.split(',') {
32 match part.split_once('=') {
33 Some(("t", v)) => timestamp = v.parse::<i64>().ok(),
34 Some(("v1", v)) => expected = hex::decode(v).ok(),
35 _ => {}
36 }
37 }
38
39 let (Some(ts), Some(expected)) = (timestamp, expected) else {
40 return (StatusCode::BAD_REQUEST, "malformed signature").into_response();
41 };
42
43 let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
44 if (now - ts).abs() > 300 {
45 return (StatusCode::BAD_REQUEST, "timestamp too old").into_response();
46 }
47
48 let signed_payload = format!("{ts}.{}", String::from_utf8_lossy(&body));
49 let mut mac = HmacSha256::new_from_slice(state.signing_secret.as_bytes()).unwrap();
50 mac.update(signed_payload.as_bytes());
51 let computed = mac.finalize().into_bytes();
52
53 if computed.ct_eq(&expected).unwrap_u8() != 1 {
54 return (StatusCode::UNAUTHORIZED, "invalid signature").into_response();
55 }
56
57 StatusCode::OK.into_response()
58}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Webhook authenticity comes from recomputing an HMAC over the exact signed payload with a shared secret.
- 2Comparing signatures in constant time prevents timing attacks that could leak the correct value.
- 3A timestamp tolerance window blocks replay of old, previously-valid requests.
Related explainers
php
<?php namespace App\Http\Controllers\Auth;
Rate-limited login in Laravel
authentication
rate-limiting
validation
Intermediate
9 steps
rust
use axum::{ body::Body, extract::Request, http::{header, StatusCode},
How bearer-auth middleware works in Axum
middleware
authentication
request extensions
Intermediate
7 steps
go
package auth import ( "net/http"
Building a JWT auth middleware in Gin
middleware
jwt
authentication
Intermediate
7 steps
rust
use std::borrow::Cow; fn sanitize_filename(input: &str) -> Cow<'_, str> { if input.chars().all(|c| c.is_alphanumeric() || matches!(c, '.' | '-' | '_')) {
Avoiding allocations with Cow in Rust
clone-on-write
borrowing
zero-copy
Intermediate
7 steps
rust
use axum::{ http::StatusCode, response::IntoResponse, routing::get,
Serving an SPA and API with Axum
routing
static-files
spa-fallback
Intermediate
7 steps
rust
use std::time::{Duration, Instant}; pub struct TokenBucket { capacity: f64,
A token bucket rate limiter in Rust
rate-limiting
token-bucket
lazy-refill
Intermediate
8 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/verifying-stripe-webhook-signatures-in-axum-explained-rust-b205/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.