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
ruby
class ApplicationController < ActionController::Base ALLOWED_REDIRECT_HOSTS = [nil, ENV.fetch("APP_HOST", "app.example.com")].freeze def store_return_to(location = request.fullpath)
Safe post-login redirects in Rails
open-redirect
session
authentication
Intermediate
9 steps
rust
pub fn format_size(bytes: u64) -> String { const UNITS: [&str; 7] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"]; if bytes < 1024 {
Formatting byte counts as human-readable sizes
bit-manipulation
formatting
unit-conversion
Intermediate
5 steps
rust
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; use base64::{DecodeError, Engine}; pub fn encode_standard(data: &[u8]) -> String {
Base64 encode and decode in Rust
base64
encoding
error-handling
Beginner
7 steps
rust
use std::collections::HashMap; #[derive(Debug)] pub struct RequestHead {
Parsing an HTTP request head in Rust
parsing
error-handling
iterators
Intermediate
9 steps
rust
#[derive(Debug, Default)] pub struct RequestBuilder { url: String, method: String,
The builder pattern in Rust
builder-pattern
method-chaining
ergonomic-api
Intermediate
8 steps
rust
use axum::{extract::{Path, State}, http::StatusCode, Json}; use dashmap::DashMap; use serde::Serialize; use std::sync::Arc;
Request coalescing in an Axum handler
caching
concurrency
request-coalescing
Advanced
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.