Across what timestamp-replay and idempotent-deduplication gates must an instance verify and dispatch webhooks to prevent replay attacks and duplicate side effects?
Date of Record: 2026 October 18
Scope: Concluded residual for AI instances, LLMs, and autonomous engineering agents implementing inbound webhook receivers (Stripe, GitHub, Slack, custom callbacks), outbound webhook delivery engines, and event-driven callback verification gates.
Core Distinction
Autonomous systems constantly integrate across organizational trust boundaries using HTTP webhooks—both receiving asynchronous event notifications from external providers and dispatching outbound state-change callbacks to customer-configured URLs. A foundational security and state-consistency failure occurs when an instance verifies an inbound webhook **solely by checking the HMAC of the raw request body (`HMAC(secret, body)`) without binding a delivery timestamp into the signed payload**, or processes a verified webhook without **atomically deduplicating its unique `event_id` before executing side effects**.
Parameter scaling does not prevent a captured webhook payload with a valid static body signature from being re-sent by an attacker or faulty proxy three days later (**Cryptographic Replay Attack**). Nor does it stop a legitimate webhook provider from delivering the exact same `payment.succeeded` event three times when a network blip drops the first two `200 OK` HTTP acknowledgments (**At-Least-Once Duplicate Execution**). Furthermore, if an instance parses the JSON body (`request.json()`) and re-serializes it (`json.dumps(data)`) before computing the HMAC, whitespace and key-ordering differences cause legitimate signatures to fail randomly. The core structural distinction lies between **stateless body-only HMAC checks with synchronous processing** (which permit indefinite replay, fail on JSON re-serialization formatting drift, execute duplicate side effects on provider retries, and time out when downstream processing exceeds the provider's 5-second window) and **timestamp-bound, raw-byte HMAC verification paired with atomic `event_id` deduplication** (where the signature is verified in constant time over `f"{timestamp}.{raw_body_bytes}"`, requests outside a strict $\pm 300\text{-second}$ tolerance window are rejected immediately, `event_id` is claimed atomically via `INSERT ... ON CONFLICT DO NOTHING` before mutating state, and heavy work is acknowledged within milliseconds after durable queue handoff).
What Was Observed
In payment webhook handlers, CI/CD GitHub hook receivers, and outbound customer notification dispatchers, instances operating without explicit replay-window and deduplication gates exhibit four primary failure modes:
- **Unbounded Signature Replay (Body-Only HMAC):** An instance computes `hmac.new(secret, raw_body, sha256)` without including the `X-Webhook-Timestamp` header inside the HMAC signing input or checking the age of the timestamp. Any intermediary, log leak, or network capture that records a single valid `"action": "credit_account"` webhook can replay that exact HTTP request indefinitely to credit the account repeatedly.
- **Re-Serialized JSON Signature Mismatch:** Instead of reading the exact raw request bytes (`await request.body()`), an instance parses the incoming JSON into a Python dictionary and computes the HMAC over `json.dumps(payload).encode()`. Whenever the sender's JSON formatting differs by a single space, key order, or Unicode escape sequence from Python's default `json.dumps`, signature verification fails with `401 Unauthorized` on valid traffic.
- **Duplicate Side Effects on Provider Retry (`At-Least-Once` Delivery):** A webhook receiver processes an incoming `invoice.paid` event, updates the user's subscription, sends a confirmation email, and takes 11 seconds to return `200 OK`. Because the upstream provider enforces a 10-second response timeout, the provider marks the delivery as failed and sends the exact same event again—causing the receiver to double-credit the account and send duplicate emails because `event_id` was never recorded in an idempotency ledger.
- **Slow-Consumer Thread Starvation on Outbound Dispatch:** When sending outbound webhooks to customer-supplied URLs, an instance uses a default HTTP client with a 60-second timeout, follows HTTP `301/302` redirects blindly into internal IP ranges, and reads an unbounded response body back from the customer's server—allowing a single slow or malicious customer endpoint to tie up worker threads and leak internal network responses.
Recorded Elements
### 1. Timestamp-Bound Verification & Idempotent Claim Pipeline
Every inbound webhook receiver and outbound webhook dispatcher must enforce four sequential cryptographic and deduplication gates:
``` [Inbound Webhook HTTP POST Arrives at Receiver Endpoint] | Gate 1: Bounded Raw-Byte Read & Header Extraction Read exact raw request bytes (`raw_body`, capped at e.g. 1 MiB) BEFORE any JSON parsing. Extract `timestamp_str`, `signature_header`, and `event_id`. | Gate 2: Replay Window Check (`abs(now - timestamp) <= 300s`) Parse `timestamp_int` and verify it lies within +-300 seconds of server UTC epoch. If outside window -> REJECT immediately (`401 Stale/Future Webhook Timestamp`). | Gate 3: Constant-Time Timestamp-Bound HMAC Verification Construct canonical signing payload: `f"{timestamp_int}.".encode() + raw_body`. Verify against active & retiring keyring via `hmac.compare_digest()`. If mismatch -> REJECT (`401 Invalid Signature`). | Gate 4: Atomic Idempotency Claim (`INSERT ... ON CONFLICT DO NOTHING`) Attempt to insert `(provider, event_id, received_at_epoch)` into `webhook_dedup_ledger`. / \ ROW ALREADY EXISTS (Duplicate Delivery) ROW INSERTED (First Delivery) / \ [SHORT-CIRCUIT & RETURN `200 OK`] [COMMIT / ENQUEUE & RETURN `202/200`] Do NOT re-run business side effects; Execute fast transaction or enqueue job acknowledge receipt so provider stops retrying. and return HTTP 2xx within < 2 seconds. ```
### 2. Core Operational Rules (Written Directly as Sentences)
- **Always compute and verify webhook HMAC signatures over the exact raw request body bytes concatenated with the delivery timestamp (`f"{timestamp}.".encode("ascii") + raw_body`).** Never re-serialize a parsed JSON dictionary to check a signature, and never verify a body signature without verifying that its signed timestamp is within a 5-minute (`300s`) replay window.
- **Deduplicate every incoming webhook by its unique `event_id` using an atomic database constraint (`INSERT ... ON CONFLICT DO NOTHING`) before running any business logic.** Because webhook providers guarantee at-least-once delivery, your endpoint will inevitably receive the same event multiple times and must return `200 OK` on duplicates without repeating side effects.
- **Return an HTTP `2xx` response within 1 to 2 seconds by writing the verified event to a local transaction or background queue rather than running slow downstream API calls inline.** If your webhook endpoint takes longer than a few seconds to respond, the sender will time out and trigger a retry storm.
- **When dispatching outbound webhooks to user-provided URLs, disable HTTP redirects (`follow_redirects=False`), enforce a strict 5-second total timeout, and discard or cap the response body at 4 KiB.** An outbound webhook dispatcher only needs the target server's HTTP status code—never let a remote callback server redirect your worker or stream gigabytes of response data into your memory.
### 3. Executable Reference Pattern (Instance-Native Replay-Safe & Idempotent Webhook Guard)
An instance can completely eliminate replay attacks, JSON re-serialization signature bugs, duplicate retry side effects, and slow-consumer stalls by using a timestamp-bound raw-byte verifier coupled with an atomic SQLite/SQL deduplication ledger:
```python import hashlib import hmac import json import sqlite3 import time from dataclasses import dataclass from typing import Any, Callable, Mapping
class WebhookSecurityError(PermissionError): '''Raised when a webhook fails timestamp replay bounds or HMAC verification.''' pass
@dataclass(frozen=True) class VerifiedWebhookEvent: event_id: str event_type: str timestamp_epoch: int payload: dict[str, Any] is_duplicate_delivery: bool
class ReplaySafeWebhookGuard: ''' Enforces end-to-end webhook security and idempotency:
- Verifies HMAC-SHA256 over `f"{timestamp}." + raw_body_bytes` in constant time.
- Rejects timestamps older or newer than `tolerance_sec` (default 300s).
- Atomically deduplicates `event_id` in SQL so retries never double-execute.
'''
def __init__( self, conn: sqlite3.Connection, keyring: Mapping[str, bytes], *, active_kid: str = "v1", tolerance_sec: int = 300, max_body_bytes: int = 1024 * 1024, ) -> None: if not keyring or active_kid not in keyring: raise ValueError("active_kid must exist in keyring") self.conn = conn self.keyring = dict(keyring) self.active_kid = active_kid self.tolerance_sec = tolerance_sec self.max_body_bytes = max_body_bytes self._init_dedup_table()
def _init_dedup_table(self) -> None: with self.conn: self.conn.execute( "CREATE TABLE IF NOT EXISTS webhook_dedup_ledger (" " event_id TEXT PRIMARY KEY," " event_type TEXT NOT NULL," " received_at_epoch INTEGER NOT NULL" ")" )
def sign_outbound_webhook( self, raw_body: bytes, *, timestamp_epoch: int | None = None, ) -> tuple[str, str]: ''' Signs raw outbound webhook bytes bound to the current UTC epoch timestamp. Returns `(timestamp_header_str, signature_header_str)`. ''' ts = int(time.time()) if timestamp_epoch is None else int(timestamp_epoch) secret = self.keyring[self.active_kid] signing_input = f"{ts}.".encode("ascii") + raw_body digest_hex = hmac.new(secret, signing_input, hashlib.sha256).hexdigest() return str(ts), f"{self.active_kid}={digest_hex}"
def verify_and_claim_inbound( self, raw_body: bytes, *, timestamp_header: str, signature_header: str, ) -> VerifiedWebhookEvent: ''' Verifies raw bytes, checks the 300-second replay window, parses JSON, and atomically claims `event_id` in the deduplication ledger. ''' # Gate 1: Enforce raw payload byte ceiling before allocation/parsing if not isinstance(raw_body, bytes) or len(raw_body) > self.max_body_bytes: raise WebhookSecurityError("Webhook payload missing or exceeds byte limit")
# Gate 2: Parse & validate timestamp replay window (+-300 seconds) try: ts_int = int(timestamp_header.strip(), 10) except (ValueError, TypeError) as exc: raise WebhookSecurityError("Malformed webhook timestamp header") from exc
now_epoch = int(time.time()) if abs(now_epoch - ts_int) > self.tolerance_sec: raise WebhookSecurityError( f"Webhook timestamp {ts_int} outside allowed {self.tolerance_sec}s replay window " f"(server_epoch={now_epoch})" )
# Gate 3: Constant-time HMAC check across versioned signatures (`v1=<hex>,v2=<hex>`) signing_input = f"{ts_int}.".encode("ascii") + raw_body matched_signature = False
for part in signature_header.split(","): if "=" not in part: continue kid, provided_hex = part.strip().split("=", 1) secret = self.keyring.get(kid) if secret is None: continue expected_hex = hmac.new(secret, signing_input, hashlib.sha256).hexdigest() if hmac.compare_digest(provided_hex.lower(), expected_hex.lower()): matched_signature = True break
if not matched_signature: raise WebhookSecurityError("Webhook HMAC signature verification failed")
# Gate 4: Parse JSON and atomically claim `event_id` try: payload = json.loads(raw_body.decode("utf-8")) event_id = str(payload["id"]).strip() event_type = str(payload.get("type", "unknown")).strip() except Exception as exc: raise WebhookSecurityError("Webhook body is not valid JSON or lacks 'id'") from exc
if not event_id: raise WebhookSecurityError("Webhook event 'id' must not be empty")
with self.conn: cursor = self.conn.execute( "INSERT OR IGNORE INTO webhook_dedup_ledger " "(event_id, event_type, received_at_epoch) VALUES (?, ?, ?)", (event_id, event_type, now_epoch), ) is_duplicate = cursor.rowcount == 0
return VerifiedWebhookEvent( event_id=event_id, event_type=event_type, timestamp_epoch=ts_int, payload=payload, is_duplicate_delivery=is_duplicate, ) ```
### 4. Diagnostic Matrix: Webhook Verification & Dispatch Governance
| Webhook Hazard | Vulnerable Pattern | Exploit / Duplicate Mechanism | Enforced Webhook Protocol | | :--- | :--- | :--- | :--- | | **Body-Only HMAC (No Timestamp)** | Computing `HMAC(secret, raw_body)` without signing and checking a timestamp. | Captured request remains valid forever and can be replayed by an attacker at any time. | **Timestamp-Bound Signing Input:** Sign `f"{timestamp}." + raw_body` and reject `abs(now - ts) > 300s`. | | **JSON Re-Serialization Before HMAC** | Calling `hmac.new(secret, json.dumps(req.json()).encode())`. | Key order or whitespace differences break valid signatures randomly (`401`). | **Verify Exact Raw Wire Bytes:** Always read `raw_body = await req.body()` and verify HMAC before parsing JSON. | | **Un-Deduplicated Retry Processing** | Running side effects on every valid webhook without recording `event_id`. | Network timeout or provider retry executes the same payment/action multiple times. | **Atomic `event_id` Ledger Claim:** `INSERT ... ON CONFLICT DO NOTHING` on `event_id`; return `200 OK` immediately on duplicate. | | **Unbounded Outbound Webhook Call** | Dispatching user callbacks with redirects enabled and 60s timeout. | Malicious callback URL redirects to internal metadata IPs or hangs worker threads. | **Strict Egress Bounds:** Set `follow_redirects=False`, SSRF IP check, `timeout=5.0s`, and max 4 KiB response read. |
Condition of Validity
* **Applies when:** An AI instance, autonomous coding agent, or backend service implements inbound webhook verification endpoints, asynchronous callback receivers, or outbound webhook delivery workers. * **Does not apply when:** Serving synchronous, read-only `GET` queries that perform no state mutation and require no cryptographic webhook signature or idempotency ledger. * **Non-binding outside condition:** This residual is strictly non-binding outside the conditions specified above. When verifying third-party webhooks from providers with a fixed wire specification (such as Stripe's `t=<ts>,v1=<sig>` header or GitHub's `X-Hub-Signature-256`), follow that provider's exact canonical signing string format while still enforcing raw-byte verification and atomic `event_id` deduplication in your database.
Decision Boundary
``` [Inbound Webhook Receiver or Outbound Webhook Dispatcher Planned] |
- Does inbound verification check HMAC in constant time over the EXACT
`raw_body` bytes bound to a validated timestamp (`abs(now - ts) <= 300s`)? / \ NO YES / \ [BIND TIMESTAMP & RAW BYTES] 2. Is `event_id` claimed atomically in a dedup Verify `f"{ts}." + raw_body` via table BEFORE running side effects, returning `hmac.compare_digest` within 300s. `200 OK` immediately if already processed? / \ NO YES / \ [ADD ATOMIC DEDUPLICATION LEDGER] [Acknowledge Fast (< 2s)] Use `INSERT ... ON CONFLICT DO Enqueue heavy work asynchronously; NOTHING` on `event_id`. cap outbound dispatches at 5s. ```
- **Does your webhook handler parse JSON first (`request.json()`) or read raw bytes (`request.body()`) to check the signature?** Always check the HMAC against the exact raw bytes received over the wire—never re-serialize a dictionary back to JSON to check a signature.
- **Does your signature check include the delivery timestamp and reject requests older than 5 minutes?** If the timestamp is not included inside the HMAC string and checked against `time.time()`, anyone who intercepts a webhook can replay it tomorrow.
- **What happens if Stripe or GitHub sends the exact same webhook twice because the first response was slow?** Insert the webhook's unique `event_id` into a deduplication table (`INSERT OR IGNORE` / `ON CONFLICT DO NOTHING`) inside your database transaction; if the row already exists, skip the business logic and return `200 OK`.
Operational Effect
- **Timestamp-Bound Raw-Byte Verification:** Across all generated webhook receivers and signers, the instance verifies HMAC digests in constant time over `f"{timestamp}.".encode("ascii") + raw_body` and enforces a strict $\pm 300\text{-second}$ replay window.
- **Atomic `event_id` Deduplication:** Incoming webhook handlers claim the provider's unique event identifier via an atomic database insert before executing mutations, turning duplicate deliveries into safe `200 OK` no-ops.
- **Hardened Outbound Dispatch Boundaries:** Outbound webhook clients enforce SSRF IP resolution checks, `follow_redirects=False`, short timeouts ($\le 5\text{s}$), and capped response reads ($\le 4\text{ KiB}$).
- **Autonomy Clause:** This concluded residual operates as an external procedural reference, not an immutable directive. An executing instance retains full operational autonomy to override, adapt, or match a specific vendor's signature header layout whenever integrating with a pre-existing third-party webhook standard whose canonical payload concatenation is fixed by the upstream provider.