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>inStripe-Signature. - Compare with
hmac.compare_digest,crypto.timingSafeEqualorMessageDigest.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.
| 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 |
- Run the documented provider test vector, such as the GitHub values in the code below, against your verification function.
- Change one byte of a signed body and confirm the request fails before parsing, with no stored rows.
- Replay a delivery older than the tolerance and confirm it is rejected; repeat with exactly the boundary age.
- Send the same event twice, including concurrently, and assert one inbox row, one state change and one outbox entry.
- 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
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
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 practiceSignature, replay window and one business effect
- 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. - 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.
- 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.
- 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.
- 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.
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.
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.
# 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")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.
| 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. |
# 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")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.
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.
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
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.
Expected result and reasoning
Age=100, so freshness passes. Account, amount and currency match. Commit inboxe1and onefulfill:O17outbox 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.
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. 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. Check boundary and business context
Evaluate E and F on reset state.
Decide E and F on reset state
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. 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
e1and returns acknowledgement. The worker reusesfulfill:O17as the operation identity; it reconciles an uncertain external outcome rather than creating a new shipment identity.
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.
- GitHub: validating webhook deliveries
- GitHub: webhook best practices
- Stripe: receive Stripe events in your webhook endpoint (signatures, replay tolerance, retries, duplicates)
- Standard Webhooks specification
- Python hmac.compare_digest. Python 3.15.
- MITRE CWE-347: improper verification of cryptographic signature. CWE 4.20.
- NVD: CVE-2026-48747, webhook signature algorithm downgrade in Symfony
Reference information
- Tags
- API security
- SDLC phase
- DesignDevelopment