Rust HMAC-SHA256: verify a webhook signature in axum

Compute HMAC-SHA256 in Rust with the hmac and sha2 crates, then verify a webhook in axum: take Bytes, MAC timestamp.body, verify_slice each entry.

6 min readSume
All posts

To compute HMAC-SHA256 in Rust, use the hmac and sha2 crates: create Hmac::<Sha256>::new_from_slice(key), feed the message with update, then call finalize() for the tag, or verify_slice(&tag) to check a tag you received in constant time. To verify a webhook in axum, take the body as Bytes rather than Json, MAC the timestamp, a dot and those raw bytes, and accept the delivery when any hex-decoded sume-v1= entry passes verify_slice.

Crate facts come from the docs.rs pages for hmac, digest::Mac, axum::extract, hex and serde_json; Sume facts come from Run webhooks, Webhooks and Verifying webhooks. All were read on 2026-09-28. Sume's SDK is TypeScript, and its docs spell out the scheme for receivers in other languages, so in Rust you write the check yourself. The Go version is in Golang: verify a webhook signature.

How do I compute HMAC-SHA256 in Rust?

Add hmac, sha2 and hex to Cargo.toml; docs.rs lists hmac 0.13.0 and sha2 0.11.0 as current, and the hmac examples import Hmac, KeyInit and Mac. new_from_slice takes a key of any size.

finalize() returns a CtOutput, whose equality check runs in constant time. into_bytes() unwraps the raw array, and the hmac docs warn that incorrect use of it may permit timing attacks. Use the bytes to send a signature, never to compare one.

use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;

fn main() {
    let mut mac = Hmac::<Sha256>::new_from_slice(b"my secret").expect("any key size");
    mac.update(b"1785000000.");
    mac.update(br#"{"event":"format.run.terminal"}"#);
    let signature = hex::encode(mac.finalize().into_bytes()); // lowercase hex
    println!("sume-v1={signature}");
}

How do I verify a webhook signature in Rust?

Sume signs <timestamp>.<raw_body> with HMAC-SHA256 and sends sume-v1=<hex_signature> in x-sume-webhook-signature, with the timestamp in x-sume-webhook-timestamp. During a signing-secret rotation, the header carries one comma-separated entry per live secret, and a delivery is valid when any entry matches.

verify_slice compares raw MAC bytes, so hex-decode each entry first. It consumes the MAC, so clone it for each entry; it rejects a tag of the wrong length, then compares with ct_eq, a constant-time equality. The function below checks every entry and refuses an empty secret, as Sume's TypeScript verifier does in current code. Each step maps to one Rust call:

From Sume's Run webhooks and Webhooks pages and the docs.rs pages for hmac, digest::Mac and hex, read 2026-09-28.
StepSume ruleRust
Replay windowReject timestamps outside it; five minutes is a reasonable defaultSystemTime::now().duration_since(UNIX_EPOCH)
MACHMAC-SHA256 over <timestamp>.<raw_body>Hmac::<Sha256>::new_from_slice, then update
EntriesAccept any sume-v1= entrysplit(','), strip_prefix("sume-v1="), hex::decode
CompareConstant timemac.clone().verify_slice(&sig)
use axum::http::HeaderMap;
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};

fn verify_sume(headers: &HeaderMap, body: &[u8], secret: &[u8]) -> bool {
    let ts = headers.get("x-sume-webhook-timestamp").and_then(|v| v.to_str().ok());
    let sigs = headers.get("x-sume-webhook-signature").and_then(|v| v.to_str().ok());
    let (Some(ts), Some(sigs)) = (ts, sigs) else { return false };
    let Ok(ts) = ts.parse::<u64>() else { return false };
    let now = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs());
    if secret.is_empty() || now.abs_diff(ts) > 300 {
        return false; // empty key, or outside the five-minute window
    }
    let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret) else { return false };
    mac.update(format!("{ts}.").as_bytes());
    mac.update(body); // the raw bytes, never re-serialized JSON
    let mut ok = false;
    for entry in sigs.split(',') {
        let Some(hex_sig) = entry.trim().strip_prefix("sume-v1=") else { continue };
        if let Ok(sig) = hex::decode(hex_sig) {
            ok |= mac.clone().verify_slice(&sig).is_ok(); // check every entry
        }
    }
    ok
}

How do I read the raw body in an axum handler?

Use the Bytes extractor, which gives you the raw request body. Json consumes the body and deserializes it, so the exact bytes you need to verify are gone. The body is a stream that can be consumed only once, and axum requires the extractor that consumes it to be the last argument: put HeaderMap first.

By default, Bytes doesn't accept bodies larger than 2MB. That fits Sume's run webhooks: a receipt over 1 MiB arrives with payload: null and a result_url to fetch it from. Parse only after the check passes, from the same bytes, with serde_json::from_slice.

// Same file as verify_sume, which already imports HeaderMap.
use axum::{body::Bytes, http::StatusCode, routing::post, Router};

async fn sume_webhook(headers: HeaderMap, body: Bytes) -> StatusCode {
    let secret = std::env::var("SUME_COM_WEBHOOK_SIGNING_SECRET").unwrap_or_default();
    if !verify_sume(&headers, &body, secret.as_bytes()) {
        return StatusCode::UNAUTHORIZED; // unset secret fails too
    }
    let Ok(event) = serde_json::from_slice::<serde_json::Value>(&body) else {
        return StatusCode::BAD_REQUEST;
    };
    record_once(&event).await; // your store or queue, keyed by request_id or job_id
    StatusCode::NO_CONTENT // answer fast, work afterwards
}

fn app() -> Router {
    Router::new().route("/hooks/sume", post(sume_webhook))
}

Why does every signature fail?

Most failures trace back to one of these:

  • The handler took Json<T>, or MACed a serde_json::Value turned back into bytes. A parsed-and-reserialized object doesn't verify, because key order and whitespace are part of what was signed.
  • The header was compared whole. During a rotation it carries one entry per live secret, so a whole-header comparison fails; split it on commas and skip entries without the sume-v1= prefix.
  • The hex string itself went to verify_slice. It expects the decoded MAC bytes, and a length mismatch is an error.
  • The secret doesn't match. Compare the x-sume-webhook-secret-fingerprint header with the fingerprint shown beside the secret in the dashboard; Debug Sume webhook delivery covers the rest.

What should the handler do after it verifies?

Record the event, answer 204 inside Sume's 10-second attempt window, and do the slow work afterwards, for example on a queue. Dedupe on request_id for run webhooks and on job_id for job webhooks, since retries repeat them, and answer 204 to an event type you don't know rather than a 500. Signed webhooks for Sume video runs covers retries and the other delivery rules.

Sources

Related posts

More in Integrations

All Integrations posts

Written by Sume