# Build a webhook receiver that withstands forged signatures, replays and duplicate events

> Stripe can resend the same event for up to three days, with a new signature each time. A time window cannot stop those duplicates, so your receiver needs a separate line of defence.

Bản gốc: https://fdetimes.net/en/guides/secure-webhook-receiver-signatures-replay-duplicates/

In live mode, Stripe attempts to deliver a webhook event for up to three days, with exponential backoff between attempts. Each retry carries a new timestamp and a new signature.

That means a five-minute anti-replay window will not stop a legitimate retry. If that is your receiver's only defence, a customer's stock can be decremented twice, or they can receive two confirmation emails for the same order.

Webhooks show up in many of the integrations a Forward Deployed Engineer may have to build: payments with Stripe, CI/CD with GitHub, internal bots with Slack. This guide walks through building a receiver with three layers of defence: signature verification, replay protection and duplicate filtering.

## What you will build, and what you need

The end result is a handful of Python functions that use only the standard library, with no framework, and mimic how Stripe signs webhooks. You need Python 3 and the `hmac`, `hashlib`, `sqlite3` and `json` modules. The HTTP layer is deliberately left out. Any framework can handle it, provided you can get at the raw bytes of the body.

One caveat before starting: the code below is simplified to teach the mechanism. In a real project, use the provider's official library, which parses the headers for you and handles details this example skips.

## Step 1: why keep the raw body?

Stripe's documentation is explicit: signature verification needs the raw request body, and the framework must not alter it. This is a common mistake when writing a receiver. Middleware parses the JSON, you serialise it again, and a single extra space or a different key order is enough to produce a completely different HMAC.

The rule is simple: capture the raw bytes first, verify the signature, and only then parse the JSON. You will see this for yourself in step 5.

## Step 2: compute the signature and compare it properly

Stripe describes how to compute the signature manually. Concatenate the timestamp, a dot and the body into `signed_payload`, then compute an HMAC with the SHA256 hash function, using the endpoint's signing secret as the key. For the comparison, Stripe requires a constant-time string comparison between the expected signature and each received signature, to defend against timing attacks.

```python
import hmac, hashlib, time

TOLERANCE_SECONDS = 300  # 5 minutes

def expected_signature(secret: bytes, timestamp: str, raw_body: bytes) -> str:
signed_payload = timestamp.encode() + b"." + raw_body
return hmac.new(secret, signed_payload, hashlib.sha256).hexdigest()

def verify(secret, timestamp, raw_body, received_sigs, now=None):
now = now if now is not None else time.time()
if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
return False
exp = expected_signature(secret, timestamp, raw_body)
return any(hmac.compare_digest(exp, s) for s in received_sigs)
```

This example is simplified: extracting the timestamp and the list of signatures from the header is skipped. Note the `any(...)` loop: several signatures may be sent, and only one needs to match. If you write `exp == s`, the code still runs and the tests still pass, but you have opened exactly the timing-attack hole Stripe warns about.

**Check:** call `expected_signature(b"whsec_test", "1700000000", b'{"id":"evt_1"}')` twice. Both results must be identical.

## Step 3: block replays, and never set the tolerance to 0

The timestamp is part of what is signed, so an attacker cannot change it without breaking the signature. Stripe's library allows a default drift of 5 minutes between the timestamp and the current time. Slack does something similar: its signature depends on the timestamp to prevent replays, and Slack's official example also checks that the drift is no more than 5 minutes.

The trap is in the configuration. Stripe warns plainly that a tolerance of `0` disables the freshness check entirely. Someone hits a clock-skew error at 2am, sets the tolerance to 0 "just to get it through", and the receiver loses its replay protection without any monitoring system raising an alarm.

**Check:** call `verify` with `now` 301 seconds later than the timestamp. The result must be `False`.

## Step 4: deduplicate by event ID, not by time

As noted at the start, a legitimate retry has a new signature, so it gets through step 3. Stripe recommends logging the event IDs you have processed and skipping any event already in the log.

Stripe also stresses that delivery order is not guaranteed. Do not use the `created` field to infer order or to guess whether an event has already been handled.

```python
import sqlite3
db = sqlite3.connect("events.db")
db.execute("CREATE TABLE IF NOT EXISTS processed (event_id TEXT PRIMARY KEY)")

def first_time(event_id: str) -> bool:
# No commit here: the caller decides when to commit
cur = db.execute(
"INSERT OR IGNORE INTO processed(event_id) VALUES (?)", (event_id,))
return cur.rowcount == 1
```

There are two subtleties. Stripe notes that two different Events sometimes describe the same thing. In that case, the deduplication key should be the ID of `data.object` combined with `event.type`.

The other is the order of writes. Record the event ID in the same transaction as the business change. If you mark the event "processed" and commit first, then the worker dies midway, that event is lost for good. That is why the function above does not commit on its own.

**Check:** `first_time("evt_1")` returns `True` on the first call and `False` on the second.

**Điểm mấu chốt:** The timestamp stops an attacker resending an old packet; only the event ID stops you from processing the same event twice.

## Step 5: return 2xx first, process later

Stripe requires endpoints to return a 2xx status quickly, before any complex logic that could cause a timeout. It recommends asynchronous processing through a queue to absorb sudden spikes in events. The condensed code below wires the previous steps into a complete receiver:

```python
import json, queue

# Dummy secret for local testing; in a real project, read the signing secret from an environment variable
SECRET = b"whsec_test"
jobs = queue.Queue()

def handle(timestamp, sigs, raw_body):
if not verify(SECRET, timestamp, raw_body, sigs):
return 400
jobs.put(json.loads(raw_body))
return 200

def worker():
while True:
event = jobs.get()
with db:  # one transaction: commit on success, roll back on error
if first_time(event["id"]):
apply_business_change(db, event)  # your business logic
```

Thanks to `with db`, recording the event ID and the business change either both succeed or are both rolled back. This is still a simplified version: an in-memory queue loses jobs if the process dies, and `apply_business_change` is yours to fill in.

Now run five tests:

1. A valid signature must pass.
2. Changing one byte of the body must fail.
3. A timestamp older than 5 minutes must fail.
4. The same event ID sent twice must be processed only once.
5. Sign a compact JSON body with no spaces, such as `b'{"id":"evt_1"}'`, then verify against `json.dumps(json.loads(raw_body)).encode()`. It must fail.

The fifth test needs a compact body because `json.dumps` does not always change the bytes. With the default separators, it inserts a space after each colon and comma, so `{"id":"evt_1"}` becomes `{"id": "evt_1"}` and the signature no longer matches. This is the most valuable test, because it reproduces exactly the middleware bug from step 1.

## Every provider signs differently

At a client, you will rarely work with Stripe alone. The mechanism is the same but the details differ, and those details are what cause bugs:

| Source | Signed string / header | Notes |
|---|---|---|
| Stripe | `timestamp.body`, HMAC-SHA256 | Default tolerance 5 minutes, never set to 0 |
| GitHub | Payload with secret token, hex digest in `X-Hub-Signature-256` | Constant-time comparison, handle the payload as UTF-8 |
| Slack | `v0:timestamp:body`, compared with `X-Slack-Signature` | Official example checks a maximum drift of 5 minutes |
| Standard Webhooks | `webhook-id`, `webhook-timestamp`, `webhook-signature` | Use `webhook-id` as the idempotency key |

If a client is building its own webhook-sending system, suggest it follows the Standard Webhooks specification. Its three headers already separate the three jobs you have just done: an ID for deduplication, a timestamp for replay protection and a signature for verification.

## Where this skill fits in FDE work

Picture your first week at a retail client. The operations team complains that "orders sometimes get recorded twice".

A good FDE does not guess, but checks in the order set out here: does the handler verify against the raw body, is there an event ID table, and does it do heavy work before returning 2xx, leading to timeouts and retries?

When job hunting, if the description mentions "integrations" or "event-driven", ask in the interview how the team handles duplicate events.

On your CV, be specific, for example: "built a webhook receiver with HMAC verification, replay protection and event-ID deduplication, with asynchronous processing via a queue". That line tells a recruiter you understand distributed systems well enough not to corrupt a client's data.

A webhook is a contract between two systems that do not trust each other, connected over an unreliable network. A good receiver assumes no packet is necessarily genuine, fresh or delivered only once.

**Thử ngay tuần này:**

- Run the five tests from step 5 with a dummy secret, including the parse-and-reserialise JSON test, to see the signature break with your own eyes.
- Open the webhook handler running in your current project and check three things: does the comparison use a constant-time function, is the tolerance set to 0, and is there a table storing event IDs?
- Write a short README comparing the headers and signed strings of Stripe, GitHub and Slack, and add it to your portfolio alongside the code.

## Nguồn

- [Receive Stripe events in your webhook endpoint](https://docs.stripe.com/webhooks)

- [Validating webhook deliveries](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries)

- [Verifying requests from Slack](https://docs.slack.dev/authentication/verifying-requests-from-slack)

- [standard-webhooks/spec/standard-webhooks.md at main · standard-webhooks/standard-webhooks · GitHub](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md)
