---
key: "webhook-verification"
title: "Verify webhook deliveries before acting"
url: "https://softwaresecurity.com/domains/appsec/secure-features/verify-webhook-deliveries/"
api: "https://softwaresecurity.com/api/v1/articles/webhook-verification.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-347","CWE-294","CWE-306","CWE-208","A08:2025","A07:2025","API10:2023"]
tags: ["API security"]
stages: ["design","build"]
roles: ["developer","appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Verify webhook deliveries before acting

Verify the provider’s signature over the correct bytes, then apply replay, authorization, and idempotency controls before a business action.

Canonical URL: https://softwaresecurity.com/domains/appsec/secure-features/verify-webhook-deliveries/

## In short

Verify the provider's signature over the exact raw request bytes before parsing, using its documented scheme and a constant-time comparison. Reject stale timestamps where the scheme signs one; Stripe's libraries allow 5 minutes. Then check that the event belongs to the expected account and order state, and record it under a unique event key in the same transaction as the state change and an outbox entry, so retries and duplicates cause one business effect.

- Verify the raw bytes before any body parser runs; re-serialized JSON is a different message.
- Use the provider scheme exactly: GitHub signs the body in `X-Hub-Signature-256`, Stripe signs `<timestamp>.<body>` in `Stripe-Signature`.
- Compare with `hmac.compare_digest`, `crypto.timingSafeEqual` or `MessageDigest.isEqual`, never `==`.
- A valid signature proves origin, not freshness, uniqueness or business validity; check each separately.
- Make the inbox key unique in the database and commit it with the state change and outbox intent, then acknowledge.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Signature algorithm | HMAC-SHA256 (GitHub, Stripe, Standard Webhooks `v1`); Ed25519 for Standard Webhooks `v1a` | — | Provider documentation; Standard Webhooks specification |
| Timestamp tolerance | 300 seconds | Stripe's library default. A tolerance of 0 disables Stripe's recency check. | Stripe: verify webhook signatures |
| Secret rotation overlap | Up to 24 hours, with one signature per active secret | — | Stripe: roll endpoint signing secrets |
| Deduplication retention | At least the provider retry window: Stripe retries live events for up to 3 days | — | Stripe: automatic retries |
| Response time | Acknowledge after the durable commit and within the provider timeout: 10 seconds for GitHub | — | GitHub: best practices for using webhooks |
| Shared secret | Random, high entropy, per endpoint and environment; Standard Webhooks uses 24 to 64 bytes | — | GitHub; Standard Webhooks specification |

## Verify it

1. Run the documented provider test vector, such as the GitHub values in the code below, against your verification function.
2. Change one byte of a signed body and confirm the request fails before parsing, with no stored rows.
3. Replay a delivery older than the tolerance and confirm it is rejected; repeat with exactly the boundary age.
4. Send the same event twice, including concurrently, and assert one inbox row, one state change and one outbox entry.
5. Rotate the secret with an overlap window and confirm both secrets verify only until the old one is retired.

## What you will learn

- Explain why parsed JSON is not the signed byte sequence.
- Separate signature, freshness, deduplication and business validation.
- Design acknowledgement and retry behavior around durable state.

Audience: Backend developers receiving signed webhooks from payment, source-control or SaaS providers, and reviewers of those handlers.

Before you start:

- Read raw HTTP body bytes, headers, and a state-changing handler.

Time: About 32 minutes: 7 to read, 5 on the code and 20 for the exercise

## Verify the exact message the provider signed

A webhook endpoint receives an ordinary HTTP request from the network. A field named `event` or `paid` is only a claim until the receiver authenticates the delivery according to its provider's protocol. In an HMAC scheme, the provider and receiver share a secret and compute a keyed digest over specified bytes. The receiver compares the supplied signature with its own computation using the protocol's constant-time comparison facility. TLS protects transport; it does not make every caller an authorized provider.

GitHub signs the raw request body with HMAC-SHA-256 in `X-Hub-Signature-256`. Stripe's signing scheme also binds a timestamp and its SDK enforces a timestamp tolerance. These are different protocols: do not invent a universal header parser or copy freshness behavior from one provider to another. JSON parsing and serialization can change whitespace, ordering or Unicode representation while preserving the same object. Verify the original bytes before body middleware transforms them; then parse and validate the authenticated content. Reject unsupported signature formats, missing secrets and malformed signatures before business work.

## Separate authenticated receipt from a business effect

A valid signature establishes possession of the delivery secret for that message, not that the event is new, belongs to the intended account or is appropriate for the current order state. For this example, a signed paid event names order O17, provider account `shop-red` and amount 2500 USD cents. The receiver loads O17, requires account `shop-red`, currency USD, amount 2500 and an allowed unpaid-to-paid transition. Trusting the event's tenant field to choose an unrestricted database context would create a new authorization failure.

Durably record a verified event and enqueue its work before returning the provider's successful acknowledgement. Deduplicate with a unique key scoped to provider/account/event identity, and atomically couple that record to the local state change or durable work intent. Two concurrent deliveries must not both pass an in-memory seen check; this is the check-then-act race in /domains/appsec/business-logic/transaction-races/. For an external effect, use the recipient's idempotency mechanism and retain the operation identity across retries. Arrival order is not necessarily business order; fetch authoritative current provider state when ordering matters.

The program below implements the training protocol from the exercise with Python and SQLite. It verifies `HMAC-SHA256(secret, "<timestamp>." + raw body)` with `hmac.compare_digest` before parsing, rejects ages above 300 seconds, and then inserts the inbox row, marks the order paid and writes `fulfill:O17` to the outbox in one transaction. A repeated event id hits the unique constraint and is acknowledged without a second effect; a new event id for an already paid order commits its inbox row but no outbox entry. Run `python3 webhook_receiver.py`; it prints `PASS: 7 delivery cases, one fulfillment`.

```python
# Run: python3 webhook_receiver.py
import hashlib
import hmac
import json
import sqlite3

TOLERANCE = 300  # seconds, inclusive


class Rejected(Exception):
    pass


def sign(secret, timestamp, raw):
    """The training protocol: HMAC-SHA256 over b"<timestamp>." + raw body."""
    return hmac.new(secret, f"{timestamp}.".encode() + raw, hashlib.sha256).hexdigest()


def verify(secret, raw, timestamp, signature, now):
    if not timestamp.isdigit() or len(signature) != 64:
        return "malformed"
    if not hmac.compare_digest(sign(secret, timestamp, raw), signature):
        return "bad_signature"
    if abs(now - int(timestamp)) > TOLERANCE:
        return "stale"
    return "ok"


def handle(db, secret, raw, headers, now):
    result = verify(secret, raw, headers.get("t", ""), headers.get("sig", ""), now)
    if result != "ok":
        return 401, result  # nothing parsed, nothing stored
    event = json.loads(raw)  # parse only the authenticated bytes
    try:
        with db:  # inbox row, state change and outbox intent commit together
            db.execute("INSERT INTO inbox VALUES (?, ?, ?)",
                       ("training", event["account"], event["id"]))
            account, amount, currency, state = db.execute(
                "SELECT account, amount, currency, state FROM orders WHERE id = ?",
                (event["order"],)).fetchone()
            if (account, amount, currency) != (event["account"], event["amount"],
                                               event["currency"]):
                raise Rejected("context_mismatch")  # rolls back the inbox row
            if state == "paid":
                return 200, "already_paid"  # new event id, same effect: no outbox
            db.execute("UPDATE orders SET state = 'paid' WHERE id = ?", (event["order"],))
            db.execute("INSERT INTO outbox VALUES (?, ?)",
                       (f"fulfill:{event['order']}", event["order"]))
        return 200, "accepted"
    except sqlite3.IntegrityError:
        return 200, "duplicate"  # already committed: acknowledge, no new effect
    except Rejected as e:
        return 400, str(e)


def fresh_db():
    db = sqlite3.connect(":memory:")
    db.executescript("""
        CREATE TABLE inbox (provider TEXT, account TEXT, event_id TEXT,
                            UNIQUE (provider, account, event_id));
        CREATE TABLE orders (id TEXT PRIMARY KEY, account TEXT, amount INTEGER,
                             currency TEXT, state TEXT);
        CREATE TABLE outbox (operation TEXT UNIQUE, order_id TEXT);
        INSERT INTO orders VALUES ('O17', 'shop-red', 2500, 'USD', 'unpaid');
    """)
    return db


SECRET = b"training-endpoint-secret"


def delivery(t, event_id, account="shop-red", amount=2500, tamper=False):
    raw = json.dumps({"id": event_id, "account": account, "order": "O17",
                      "amount": amount, "currency": "USD"}).encode()
    sig = sign(SECRET, str(t), raw)
    if tamper:
        raw = raw.replace(b", ", b",")  # same JSON object, different bytes
    return raw, {"t": str(t), "sig": sig}


db = fresh_db()
assert handle(db, SECRET, *delivery(900, "e1"), now=1000) == (200, "accepted")
assert handle(db, SECRET, *delivery(900, "e1"), now=1000) == (200, "duplicate")
assert handle(db, SECRET, *delivery(950, "e5"), now=1000) == (200, "already_paid")
assert db.execute("SELECT COUNT(*) FROM outbox").fetchone() == (1,)
for case, expected in [
    (delivery(900, "e2", tamper=True), (401, "bad_signature")),
    (delivery(699, "e2"), (401, "stale")),  # age 301
    (delivery(700, "e3", account="shop-blue"), (400, "context_mismatch")),  # age 300
    (delivery(900, "e4", amount=25), (400, "context_mismatch")),
]:
    db = fresh_db()
    assert handle(db, SECRET, *case, now=1000) == expected, (case, expected)
    assert db.execute("SELECT COUNT(*) FROM inbox").fetchone() == (0,)
    assert db.execute("SELECT state FROM orders").fetchone() == ("unpaid",)
print("PASS: 7 delivery cases, one fulfillment")

```

## Check the provider's exact signature scheme

Use the provider's library or its documented algorithm exactly; the schemes differ in the bytes they sign and in whether a timestamp is included. The function below verifies GitHub's header and is tested with the secret, payload and signature GitHub publishes for this purpose, so you can prove the function before wiring it to a route.

Compare with a constant-time function on equal-length values: `hmac.compare_digest` in Python, `crypto.timingSafeEqual` in Node.js, which throws when the lengths differ, so check the length first, and `MessageDigest.isEqual` in Java. Stripe also publishes the addresses it sends from and recommends allowlisting them in addition to verifying signatures; an address allowlist alone does not authenticate a message.

```python
# Run: python3 github_signature.py
import hashlib
import hmac


def verify_github(secret: str, raw_body: bytes, header: str | None) -> bool:
    """Checks X-Hub-Signature-256 against the raw request body."""
    if not header or not header.startswith("sha256="):
        return False
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header)


# Test values published in GitHub's "Validating webhook deliveries" guide.
secret = "It's a Secret to Everybody"
header = "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17"
assert verify_github(secret, b"Hello, World!", header)
assert not verify_github(secret, b"Hello, World! ", header)  # one extra byte
assert not verify_github("wrong secret", b"Hello, World!", header)
assert not verify_github(secret, b"Hello, World!", None)
print("PASS: GitHub test vector verified")

```

| Scheme | Signature header | Signed bytes | Freshness and duplicates |
| --- | --- | --- | --- |
| GitHub | `X-Hub-Signature-256: sha256=<hex>` | The raw body | No timestamp is signed; deduplicate on `X-GitHub-Delivery`, which a redelivery repeats. |
| Stripe | `Stripe-Signature: t=<time>,v1=<hex>` | `<t>.` followed by the raw body | Libraries reject timestamps more than 5 minutes old; each retry has a new timestamp and signature; ignore schemes other than `v1`. |
| Standard Webhooks | `webhook-id`, `webhook-timestamp`, `webhook-signature: v1,<base64>` | `<id>.<timestamp>.<body>` | The receiver sets the tolerance; several space-separated signatures allow rotation; `v1a` uses Ed25519. |

## Diagnose signature, retry and rotation failures independently

A legitimate signature failure is often a raw-body mismatch, wrong endpoint secret, header parsing problem or timestamp clock problem. Compare byte lengths and request identifiers, never print production signing secrets. Keep reverse proxies and frameworks from silently decoding or rewriting a signed body. Verify only with secrets configured for the receiving endpoint; a submitted key id or URL must not tell the server where to fetch an arbitrary secret.

During a planned rotation, an explicitly bounded overlap may allow old and new configured secrets if the provider supports that arrangement. Measure which secret version verified a delivery and retire the old one after the supported retry window. Keep freshness and deduplication separate: a timestamp can make a captured request too old, but multiple deliveries within the accepted interval can still duplicate work. Conversely, a provider retry may carry fresh signature material for the same event id. Verification tests should assert inbox rows, order state and outbox effects, not merely a 2xx response. The lab at /labs/webhooks/ asks you to fix a handler that pays twice, using these same checks.

## A real-world failure

CVE-2026-48747 was a signature-algorithm downgrade in Symfony's Mailomat webhook parser: it read the HMAC algorithm from the request's own signature header and passed it to `hash_hmac()`, so an attacker could pick a weak algorithm instead of the documented SHA-256. The missing control was pinning the algorithm server-side, as this guide does.

## Handle event order without inventing a provider guarantee

Imagine an invoice is paid and then refunded, but the refund delivery arrives before an earlier paid delivery. A receiver that blindly applies each event as a new status can finish with paid, contradicting the provider's current state. Deduplicating event ids does not solve this because the two events are distinct. Choose a state-transition policy that tolerates ordering, or retrieve the provider's authoritative current object when the event is a notification of change. Do not sort by an arbitrary delivery timestamp unless the provider explicitly gives that field the ordering semantics you need.

Different event ids may also describe the same business effect. A unique inbox event key prevents processing the identical event twice, while a unique fulfillment operation for O17 prevents two different accepted notifications from shipping the same order twice. Those constraints belong at different semantic levels. In the pseudocode, record the paid transition and fulfillment intent transactionally. If a duplicate business event arrives after payment, recognize the already-completed transition and avoid another outbox effect according to the operation contract.

Verification should include a response lost after durable commit, a worker crash after external acceptance, two simultaneous copies of one event and two different event ids about the same order. Count both inbox receipts and business effects. Two verified inbox rows can be legitimate, while two shipments for one paid order are not. This distinction makes operational dashboards useful during provider retries rather than treating every duplicate as an attack.

## Guided practice

Evaluate a synthetic signed-event protocol without contacting a provider. Signature validity is supplied evidence; the fixtures are synthetic rather than real GitHub or Stripe deliveries.

### Supplied material

```text
Training protocol: signature covers exact raw body and timestamp; accept abs(now-timestamp)<=300 seconds. now=1000. Verify signature before parsing. Unique inbox key=(provider,account,eventId). O17 belongs to shop-red, unpaid, amount=2500,currency=USD.
A: valid signature; t=900; id=e1; account=shop-red; O17; 2500 USD.
B: repeat A after its commit.
C: signature invalid after body whitespace was changed; t=900.
D: valid signature; t=699; id=e2; same order details.
E: valid signature; t=700; id=e3; account=shop-blue; same order.
F: valid signature; t=900; id=e4; account=shop-red; 25 USD.
For independent cases C–F, reset O17 to unpaid and clear inbox/outbox. Ack only after durable commit.
```

### 1. Accept the normal event

Process A and list the durable records and action.

Worked answer: `Age=100`, so freshness passes. Account, amount and currency match. Commit inbox `e1` and one `fulfill:O17` outbox intent; the worker can later fulfill once.

### 2. Handle duplicates and concurrent retries

Process B, then imagine two copies of A arrive simultaneously before either checks the inbox.

Worked answer: B produces no second intent. A database uniqueness constraint selects one successful insert under concurrency; a plain read-then-insert check is insufficient. A committed duplicate receives a success acknowledgement to avoid pointless retries.

### 3. Separate byte and time checks

Evaluate C and D, including D's precise age.

Worked answer: C fails authentication even if parsed JSON is unchanged, because the raw signed bytes changed. D has age 301 and fails freshness. Both cause no business mutation.

### 4. Check boundary and business context

Evaluate E and F on reset state.

Worked answer: E is age 300 and passes the time boundary but fails account binding. F passes signature and freshness but fails amount equality: 25 differs from 2500. Neither creates fulfillment.

### 5. Recover from an acknowledgement loss

The receiver committed A but its acknowledgement was lost. State what the provider retry and worker retry should do.

Worked answer: The inbox rejects duplicate `e1` and returns acknowledgement. The worker reuses `fulfill:O17` as the operation identity; it reconciles an uncertain external outcome rather than creating a new shipment identity.

Deliverable: A five-stage receipt sequence and exact accept/reject/effect outcomes for A–F.

Scope: Paper exercise: signature validity is supplied for each delivery, so no provider account or network is needed. `webhook_receiver.py` above runs the same cases against real HMAC signatures and a SQLite inbox.

## Executable practice

- [Webhook signature and replay lab: Repair a webhook handler that pays twice](https://softwaresecurity.com/labs/webhooks/)

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [GitHub: validating webhook deliveries](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries)
- [GitHub: webhook best practices](https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks)
- [Stripe: receive Stripe events in your webhook endpoint (signatures, replay tolerance, retries, duplicates)](https://docs.stripe.com/webhooks)
- [Standard Webhooks specification](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md)
- [Python hmac.compare_digest](https://docs.python.org/3/library/hmac.html#hmac.compare_digest). Python 3.15.
- [MITRE CWE-347: improper verification of cryptographic signature](https://cwe.mitre.org/data/definitions/347.html). CWE 4.20.
- [NVD: CVE-2026-48747, webhook signature algorithm downgrade in Symfony](https://nvd.nist.gov/vuln/detail/cve-2026-48747)