DROPLESSBlogDocsGitHub

Verifying inbound webhooks: Stripe, GitHub, and HMAC

2026-06-24 · 7 min read

An attacker who finds your /webhooks/stripe URL can POST whatever JSON they like to it. If your handler trusts the body, they can mark invoices paid, flip a subscription to active, or merge a fake pull-request event. The URL is public; the only thing standing between that request and your database is the signature on it. Getting verification right is therefore not a nice-to-have, and the failure modes are subtle: a naive == on the signature string leaks timing, skipping the timestamp leaves you open to replay, and parsing the body before you verify it hands an attacker your JSON deserializer.

This post walks through verifying inbound webhooks from Stripe and GitHub, plus a generic HMAC scheme, the way Dropless's inbound gateway does it. All three are HMAC-SHA256 underneath; the differences are in what gets signed, how the signature is encoded, and which header carries it.

The shared primitive: HMAC-SHA256 signature verification

Every provider here computes HMAC-SHA256(secret, signed_content) and ships the result in a header. Your job is to recompute the same MAC over the same bytes with the same secret and check that it matches. Two rules apply regardless of provider:

Here is the core, taken from Dropless's inbound.rs. The constant-time compare is deliberately boring:

fn hmac_hex(key: &[u8], msg: &[u8]) -> String {
    let mut mac = HmacSha256::new_from_slice(key).expect("hmac accepts any key length");
    mac.update(msg);
    hex::encode(mac.finalize().into_bytes())
}

/// Constant-time byte comparison (no early return on first mismatch).
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut diff = 0u8;
    for (x, y) in a.iter().zip(b.iter()) {
        diff |= x ^ y;
    }
    diff == 0
}

The length check short-circuits, which is fine — the length of a hex SHA-256 digest is fixed and public. What must not short-circuit is the content comparison, and the diff |= x ^ y loop guarantees it touches every byte.

How to verify a Stripe webhook signature

Stripe sends a Stripe-Signature header that looks like t=1700000000,v1=23c920…,v1=…. The t is the Unix timestamp Stripe signed at; each v1 is a candidate signature (there can be more than one during a secret rotation). The signed content is "{t}.{body}" — the timestamp, a literal dot, then the raw body — and the key is your endpoint's signing secret used as-is (the whsec_ string itself, not base64-decoded).

Two things matter beyond the MAC. First, the timestamp is folded into the signed payload, so an attacker cannot replay an old request with a fresh t — changing t invalidates the signature. Second, you should still reject timestamps outside a tolerance window so a captured-and-resent request expires:

pub fn verify_stripe(
    secret: &str, sig_header: &str, body: &[u8], now: i64, tolerance_secs: i64,
) -> Result<(), CoreError> {
    let mut ts: Option<i64> = None;
    let mut v1s: Vec<&str> = Vec::new();
    for part in sig_header.split(',') {
        let Some((k, v)) = part.split_once('=') else { continue };
        match k.trim() {
            "t"  => ts = v.trim().parse().ok(),
            "v1" => v1s.push(v.trim()),
            _ => {}
        }
    }
    let ts = ts.ok_or_else(|| reject("stripe: missing timestamp"))?;
    if tolerance_secs > 0 && (now - ts).abs() > tolerance_secs {
        return Err(reject("stripe: timestamp outside tolerance"));
    }
    let mut signed = Vec::with_capacity(body.len() + 12);
    signed.extend_from_slice(ts.to_string().as_bytes());
    signed.push(b'.');
    signed.extend_from_slice(body);
    let expected = hmac_hex(secret.as_bytes(), &signed);
    if v1s.iter().any(|v| ct_eq(v.as_bytes(), expected.as_bytes())) {
        Ok(())
    } else {
        Err(reject("stripe: no matching v1 signature"))
    }
}

Dropless uses a 300-second tolerance. Note the loop over every v1 token: during rotation Stripe signs with both the old and new secret, and you match if any candidate verifies. Unknown scheme tokens (v0=…, etc.) are simply ignored — a known-answer test in the crate asserts that a header carrying v0=deadbeef alongside a valid v1 still verifies.

Webhook replay protection for Stripe

Timestamp tolerance narrows the replay window but does not close it — within 300 seconds, the same signed bytes verify every time. That is by design (you want the provider's retries to succeed), which means replay protection lives one layer down, in dedupe, covered below.

How to verify a GitHub webhook signature

GitHub is the simplest of the three. It sends X-Hub-Signature-256: sha256=<hex>, the secret is used as-is, and the signed content is the raw body verbatim — no timestamp prefix. Strip the sha256= prefix, recompute, compare in constant time:

pub fn verify_github(secret: &str, sig_header: &str, body: &[u8]) -> Result<(), CoreError> {
    let provided = sig_header.strip_prefix("sha256=").unwrap_or(sig_header);
    let expected = hmac_hex(secret.as_bytes(), body);
    if ct_eq(provided.as_bytes(), expected.as_bytes()) {
        Ok(())
    } else {
        Err(reject("github: signature mismatch"))
    }
}

Because there is no signed timestamp, GitHub gives you nothing to enforce a tolerance window against. GitHub does send an X-GitHub-Delivery id, but it is not covered by the signature, so you cannot trust it for replay protection — an attacker replaying a captured request can set it to anything. The safe dedupe key is a SHA-256 of the signed body itself.

The "persist raw body first" rule

Verification dictates an ordering constraint that is easy to get backwards. You must read the raw bytes, verify them, and persist them before you parse, validate, or act on the payload. The reasons compound:

In Dropless this is one Postgres transaction: persist the raw body, create the outbound event, fan out to the tenant's endpoints, link the rows, and only then return 2xx. The provider is acknowledged after the commit, never before — the same commit-then-2xx invariant the outbound pipeline uses, so a crash commits everything or nothing and there is no acknowledged-but-lost event. Postgres is the only moving part here: it is both the durable store and, downstream, the delivery queue (no Redis, no Kafka).

Dedupe on signed material to actually stop replay

Delivery is at-least-once, not exactly-once: providers retry, and a retry is indistinguishable from a replay at the HTTP layer. The fix is idempotent ingest — derive a dedupe key from signed material only, and let the database reject the second insert. Stripe's event id lives inside the signed body, so it is trustworthy; GitHub's delivery id is unsigned, so you fall back to a SHA-256 of the (signed) body. A partial unique index turns the duplicate into a no-op:

CREATE UNIQUE INDEX inbound_events_dedup_idx
    ON inbound_events (source_id, source_event_id)
    WHERE source_event_id IS NOT NULL;

-- on insert:
INSERT INTO inbound_events (id, source_id, tenant_id, source_event_id, event_type, raw_body)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (source_id, source_event_id) WHERE source_event_id IS NOT NULL DO NOTHING
RETURNING id;

If RETURNING yields no row, the event was already accepted — Dropless rolls the transaction back, returns 200 {"status":"duplicate"}, and fans out nothing. Because the key is bound to signed bytes, a replay of the same captured request always collides, closing the fan-out-amplification hole even when the provider supplies no usable id of its own.

This is also why receivers downstream of Dropless get an immutable Idempotency-Key on every outbound request: at-least-once means your own consumers must dedupe too, and the key gives them a stable handle to do it.

One more hardening detail: don't be an oracle

A subtle leak: if an unknown slug returns 404 and a bad signature returns 401, your public endpoint becomes a slug-existence and provider oracle. Dropless returns an identical 401 {"error":"unauthorized"} for "no such source," "source disabled," and "signature mismatch." The attacker should learn nothing from the status code.

Wrapping up

The pattern is the same across providers: recompute HMAC-SHA256 over the exact signed content, compare in constant time, enforce a timestamp tolerance where the provider signs one, persist the raw bytes before parsing, and dedupe on signed material so retries are safe and replays are inert.

Dropless implements all of this — Stripe, GitHub, and generic HMAC verification, raw-first persistence, and signed-material dedupe — in its inbound gateway, with known-answer tests pinning every code path above. It is open source under AGPL-3.0 if you want to read the real code or run it yourself: github.com/r4nd0mENRYU/dropless (Docker image at ghcr.io/r4nd0menryu/dropless), or try the hosted version.

Dropless implements everything above — open source (AGPL-3.0): github.com/r4nd0mENRYU/dropless · ← more posts