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.

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.
Applies to

Signed HTTP deliveries with retries. Provider signature formats and timestamp rules remain provider-specific.

Part of
Learning paths: Developer foundations (step 8 of 13), AppSec in practice (step 11 of 18). Workflows: Fix a finding, Secure a feature

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 32 minutes: 7 to read, 5 on the code and 20 for the exercise

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.

Before you start

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

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

Refresh first: Secrets management: storage, rotation and leaked-secret response

Go to the guided practice

Signature, replay window and one business effect

Your serviceWebhookproviderAttacker(replay)ReceiverendpointDatabaseWorkerSigned delivery: timestamp, event id,body1Check HMAC ofthe raw bytes2Replay of acaptured delivery3Reject ifolder than300 seconds3One transaction:inbox, orderchecks, outbox4Duplicate eventid: no new effect4Acknowledge after commit5Outbox intentfulfill:O175Stableoperation IDto recipient5Trust boundaryUntrusted input1Step, described below
  1. Signed deliveryThe provider sends the event with a signature header. In a timestamped scheme such as Stripe’s or the exercise protocol, the signed bytes are <timestamp>.<raw body>. Until verified, the request is untrusted.
  2. Verify the signatureBefore any body parser runs, the receiver reads the raw body within a size limit, recomputes the HMAC with the secret and algorithm configured for this endpoint, and compares in constant time.
  3. Check the replay windowThe receiver rejects a signed timestamp older than the tolerance (300 seconds here). A captured delivery replayed inside the window still passes this check; only the unique inbox key stops it.
  4. Record onceOne transaction inserts the event id into the inbox under a unique constraint, checks account, amount and order state, marks the order paid and writes the outbox intent. A replayed or retried event id hits the constraint and is acknowledged with no new effect.
  5. Acknowledge and fulfillThe receiver acknowledges after the commit. The worker reads the outbox and calls the recipient with the stable operation id fulfill:O17, so its own retries are safe.
The signature proves origin, the timestamp bounds how long a captured delivery can be replayed, and the unique inbox key makes a replay or retry inside that window a no-op.

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.

Fixedwebhook_receiver.pyPython
# 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.

Check the provider's exact signature scheme
SchemeSignature headerSigned bytesFreshness and duplicates
GitHubX-Hub-Signature-256: sha256=<hex>The raw bodyNo timestamp is signed; deduplicate on X-GitHub-Delivery, which a redelivery repeats.
StripeStripe-Signature: t=<time>,v1=<hex><t>. followed by the raw bodyLibraries reject timestamps more than 5 minutes old; each retry has a new timestamp and signature; ignore schemes other than v1.
Standard Webhookswebhook-id, webhook-timestamp, webhook-signature: v1,<base64><id>.<timestamp>.<body>The receiver sets the tolerance; several space-separated signatures allow rotation; v1a uses Ed25519.
Testgithub_signature.pyPython
# 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")

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 scenario material
Plain 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. 1. Accept the normal event

    Process A and list the durable records and action.

    Expected result and reasoning

    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. 2. Handle duplicates and concurrent retries

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

    Expected result and reasoning

    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. 3. Separate byte and time checks

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

    Expected result and reasoning

    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. 4. Check boundary and business context

    Evaluate E and F on reset state.

    Decide E and F on reset state

    E: valid signature, t=700, account shop-blue
    F: valid signature, t=900, 25 USD

    Expected result and reasoning

    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. 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.

    Expected result and reasoning

    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.

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

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.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Repair the code

Webhook signature and replay lab: Repair a webhook handler that pays twice

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. GitHub: validating webhook deliveries
  2. GitHub: webhook best practices
  3. Stripe: receive Stripe events in your webhook endpoint (signatures, replay tolerance, retries, duplicates)
  4. Standard Webhooks specification
  5. Python hmac.compare_digest. Python 3.15.
  6. MITRE CWE-347: improper verification of cryptographic signature. CWE 4.20.
  7. NVD: CVE-2026-48747, webhook signature algorithm downgrade in Symfony

Reference information

Download this guide as Markdown