Verifying HMAC Signatures on Inbound Work Order Webhooks to Stop Forged and Replayed Deliveries

An inbound webhook intake adapter that accepts a POST without properly verifying its signature will faithfully convert any correctly-shaped JSON body into a work order — including one an attacker forged from outside the network, or one a retrying sender’s delivery queue resent an hour later. This failure sits squarely inside work order ingestion and parsing pipelines: the fix is not a firewall rule or an API gateway allowlist, it is computing an HMAC-SHA256 signature over the raw request body with the shared secret, comparing it in constant time, bounding acceptance to a timestamp window, and deduplicating on the delivery id before a single payload reaches the async batch processing queue.

Incident Profile

A vendor’s CMMS integration posts work orders to /webhooks/work-orders whenever a linked building-automation alarm fires. The endpoint was stood up quickly during a pilot, and the constant-time signature check, timestamp guard, and delivery dedup were left as a “follow-up” that never landed. Two failures surfaced in the same overnight window: one delivery was accepted without any signature check running at all, and a second delivery — identical to the first — was replayed 87 minutes later and created a duplicate work order against the same asset.

2026-06-30 02:14:07,220 INFO  [webhook_intake] POST /webhooks/work-orders
2026-06-30 02:14:07,221 DEBUG [webhook_intake] Headers: {'X-Signature': '3f9ad2...c102', 'X-Delivery-Id': 'dlv_a91f3c', 'Content-Type': 'application/json'}
2026-06-30 02:14:07,222 WARNING [webhook_intake] No X-Timestamp header present; signature check bypassed (verify_signature() not invoked)
2026-06-30 02:14:07,224 INFO  [webhook_intake] Payload accepted, work order WO-90112 created for asset CHILLER-07
2026-06-30 02:14:07,225 INFO  [routing_engine] WO-90112 queued, priority=critical, escalation_tier=2
2026-06-30 03:41:52,880 INFO  [webhook_intake] POST /webhooks/work-orders
2026-06-30 03:41:52,881 DEBUG [webhook_intake] Headers: {'X-Delivery-Id': 'dlv_a91f3c', 'Content-Type': 'application/json'}
2026-06-30 03:41:52,884 INFO  [webhook_intake] Payload accepted, work order WO-90178 created for asset CHILLER-07
2026-06-30 03:41:52,885 ERROR [dispatch_audit] Duplicate technician dispatched: WO-90112 and WO-90178 reference identical delivery dlv_a91f3c, 87 minutes apart

The tell is in what is missing from the second request, not what is present in it: the same X-Delivery-Id arrives twice, no code path ever rejected it, and a technician was dispatched twice against a single alarm. Nothing about either request looked anomalous to the adapter, because the adapter never asked whether it should trust the request in the first place.

Webhook signature verification decision flow A decision ladder for an inbound work order webhook. An incoming POST first has its HMAC-SHA256 signature checked with a constant-time compare; a mismatch is rejected with HTTP 401 as a forged signature. A match proceeds to a timestamp freshness check; a timestamp outside the tolerance window is rejected with HTTP 401 as an expired or stale delivery. A fresh timestamp proceeds to a delivery-id dedup check; an id seen before is rejected with HTTP 200 as a duplicate ignored, stopping a replay from creating a second work order. Only a request that passes all three checks is mapped to the canonical WorkOrderPayload and enqueued. Three gates between a POST and a work order Inbound POST raw body + headers compare_digest match? HMAC-SHA256(secret, body) mismatch match 401 · forged signature no shared-secret proof; never reaches the parser timestamp fresh? within tolerance window too old / forward-dated fresh 401 · stale delivery valid signature, but outside the replay tolerance window delivery id seen? checked against dedup set seen before (replay) new id 200 · duplicate ignored acknowledged so the sender stops retrying; no second WO Map to WorkOrderPayload priority, requested_completion, escalation_tier populated enqueued for processing

Root Cause Analysis

Three distinct gaps produce the same symptom — a forged or duplicated work order — and a fix for one does not cover the others.

  1. No signature verification runs at all. The handler reads the JSON body and enqueues it directly, treating a successful POST as proof of authenticity. This is what the first log line shows: verify_signature() not invoked. Anyone who can reach the endpoint — a misrouted integration, a scanner, or an attacker who found the URL — can create work orders indistinguishable from legitimate ones.
  2. Signature verification runs, but the comparison is not constant-time. A naive signature_header == expected in Python short-circuits on the first mismatched character. Measuring response latency across many attempts leaks how many leading bytes an attacker has guessed correctly, turning a 256-bit secret into a byte-at-a-time guessing game. This variant produces no obvious log signature — the check “passes” for legitimate traffic — which is precisely why it survives code review undetected.
  3. Signature verification runs and is constant-time, but there is no timestamp tolerance or delivery-id dedup. A signature proves the body was signed by the holder of the shared secret at some point; it says nothing about when. Without a freshness window, a captured request — replayed by a retrying sender’s queue, a misconfigured proxy, or an attacker who intercepted one delivery — is re-accepted every time it arrives, exactly as the second log line shows: the identical dlv_a91f3c delivery created a second work order 87 minutes later.

All three failures point to the same underlying assumption: that a webhook body which parses successfully is a body that should be trusted. The canonical schema work happening in work order schema standards validates shape; it cannot validate provenance. Provenance is the webhook adapter’s job alone.

Resolution

The fix has to close all three gaps together — a correct HMAC check with a broken freshness window is still replayable, and a freshness window on top of a timing-leaky compare is still forgeable.

Before — no constant-time compare, no timestamp or replay guard:

import hashlib
import hmac
import json

SHARED_SECRET = b"whsec_prod_change_me"


def verify_signature(raw_body: bytes, signature_header: str) -> bool:
    expected = hmac.new(SHARED_SECRET, raw_body, hashlib.sha256).hexdigest()
    # Timing leak: a plain string compare short-circuits on the first
    # mismatched byte, letting an attacker time-probe the signature.
    return signature_header == expected


async def webhook_handler(request):
    raw_body = await request.read()
    signature_header = request.headers.get("X-Signature", "")
    if not verify_signature(raw_body, signature_header):
        return {"error": "invalid signature"}, 401

    # No timestamp check and no delivery-id dedup: a captured, validly
    # signed delivery can be replayed indefinitely and re-accepted every time.
    payload = json.loads(raw_body)
    enqueue_work_order(payload)
    return {"status": "accepted"}, 200

After — constant-time compare, bounded freshness, delivery dedup:

import hashlib
import hmac
import json
import time

SHARED_SECRET = b"whsec_prod_change_me"
TIMESTAMP_TOLERANCE_S = 300  # 5 minutes
seen_delivery_ids: set[str] = set()  # swap for Redis/DB in production


def compute_signature(raw_body: bytes, timestamp: str) -> str:
    # Sign the timestamp together with the body so a captured signature
    # cannot be replayed against a different, or a much later, delivery.
    signed_payload = f"{timestamp}.".encode() + raw_body
    return hmac.new(SHARED_SECRET, signed_payload, hashlib.sha256).hexdigest()


def verify_signature(raw_body: bytes, timestamp: str, signature_header: str) -> bool:
    expected = compute_signature(raw_body, timestamp)
    # Constant-time compare: takes the same time regardless of where the
    # first differing byte falls, closing the timing side-channel.
    return hmac.compare_digest(signature_header, expected)


def is_fresh(timestamp: str) -> bool:
    try:
        sent_at = int(timestamp)
    except ValueError:
        return False
    return abs(time.time() - sent_at) <= TIMESTAMP_TOLERANCE_S


async def webhook_handler(request):
    raw_body = await request.read()
    timestamp = request.headers.get("X-Timestamp", "")
    signature_header = request.headers.get("X-Signature", "")
    delivery_id = request.headers.get("X-Delivery-Id", "")

    if not verify_signature(raw_body, timestamp, signature_header):
        return {"error": "invalid signature"}, 401
    if not is_fresh(timestamp):
        return {"error": "stale delivery"}, 401
    if delivery_id in seen_delivery_ids:
        # Idempotent no-op: acknowledge so the sender stops retrying,
        # but never enqueue a second work order for the same delivery.
        return {"status": "duplicate_ignored"}, 200

    seen_delivery_ids.add(delivery_id)
    payload = json.loads(raw_body)
    enqueue_work_order(payload)
    return {"status": "accepted"}, 200

Three changes carry the fix: signing the timestamp alongside the body so a signature cannot be detached and reused later; comparing with hmac.compare_digest, which runs in time proportional only to the length of its inputs, never to where they first diverge; and tracking delivery ids so a resend — malicious or accidental — is acknowledged without ever reaching enqueue_work_order twice. Note that the raw bytes read from the socket must be signed, never a re-serialized json.dumps(request.json()) — re-serialization can reorder keys or change whitespace, producing a signature mismatch against a perfectly legitimate request, or worse, a signature match against a tampered one if the parser is lenient about the corruption.

Minimal Reproducible Pipeline

This script runs as-is with only the standard library. It defines a WebhookIngestor with a verify_signature-driven process_delivery method and an async handler that rejects a forged signature, an expired timestamp, and a replayed delivery id, then maps the one valid delivery to the canonical WorkOrderPayload — with the site-wide SLA fields priority, requested_completion, and escalation_tier — before it would be handed to the queue.

import asyncio
import hashlib
import hmac
import json
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Dict, List, Optional

SHARED_SECRET = b"whsec_prod_change_me"
TIMESTAMP_TOLERANCE_S = 300  # 5 minutes


class Priority(str, Enum):
    CRITICAL = "critical"
    HIGH = "high"
    STANDARD = "standard"
    PLANNED = "planned"


@dataclass
class WorkOrderPayload:
    """Canonical CMMS work order -- SLA fields are mandatory site-wide."""
    work_order_id: str
    asset_id: str
    part_skus: List[str]
    required_quantities: Dict[str, int]
    location_id: str
    priority: Priority = Priority.STANDARD
    requested_completion: Optional[datetime] = None
    escalation_tier: int = 0
    status: str = "open"
    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))


class WebhookRejected(Exception):
    def __init__(self, reason: str, status: int):
        super().__init__(reason)
        self.reason = reason
        self.status = status


class WebhookIngestor:
    """Verifies inbound webhook deliveries before they reach the queue."""

    def __init__(self, secret: bytes, tolerance_s: int = TIMESTAMP_TOLERANCE_S):
        self.secret = secret
        self.tolerance_s = tolerance_s
        self._seen_delivery_ids: set[str] = set()

    def compute_signature(self, raw_body: bytes, timestamp: str) -> str:
        signed_payload = f"{timestamp}.".encode() + raw_body
        return hmac.new(self.secret, signed_payload, hashlib.sha256).hexdigest()

    def verify_signature(self, raw_body: bytes, timestamp: str, signature_header: str) -> bool:
        expected = self.compute_signature(raw_body, timestamp)
        return hmac.compare_digest(signature_header, expected)

    def is_fresh(self, timestamp: str) -> bool:
        try:
            sent_at = int(timestamp)
        except ValueError:
            return False
        return abs(time.time() - sent_at) <= self.tolerance_s

    async def process_delivery(
        self, raw_body: bytes, timestamp: str, signature_header: str, delivery_id: str
    ) -> WorkOrderPayload:
        if not self.verify_signature(raw_body, timestamp, signature_header):
            raise WebhookRejected("invalid signature", 401)
        if not self.is_fresh(timestamp):
            raise WebhookRejected("stale or forward-dated delivery", 401)
        if delivery_id in self._seen_delivery_ids:
            raise WebhookRejected("duplicate delivery ignored", 200)

        self._seen_delivery_ids.add(delivery_id)
        body = json.loads(raw_body)
        return WorkOrderPayload(
            work_order_id=body["work_order_id"],
            asset_id=body["asset_id"],
            part_skus=body["part_skus"],
            required_quantities=body["required_quantities"],
            location_id=body["location_id"],
            priority=Priority(body.get("priority", "standard")),
            escalation_tier=int(body.get("escalation_tier", 0)),
        )


async def enqueue_work_order(wo: WorkOrderPayload) -> None:
    print(f"enqueued {wo.work_order_id} priority={wo.priority.value} tier={wo.escalation_tier}")


async def main() -> None:
    ingestor = WebhookIngestor(SHARED_SECRET)
    body = json.dumps({
        "work_order_id": "WO-91004",
        "asset_id": "CHILLER-07",
        "part_skus": ["VLV-220"],
        "required_quantities": {"VLV-220": 1},
        "location_id": "SITE-12",
        "priority": "high",
        "escalation_tier": 1,
    }).encode()

    now = str(int(time.time()))
    good_sig = ingestor.compute_signature(body, now)

    # 1. Forged: no valid signature for this body/timestamp pair.
    try:
        await ingestor.process_delivery(body, now, "deadbeef" * 8, "dlv_forged")
    except WebhookRejected as exc:
        print(f"rejected forged delivery: {exc.reason} ({exc.status})")

    # 2. Expired: correctly signed, but the timestamp is outside tolerance.
    stale_ts = str(int(time.time()) - 3600)
    stale_sig = ingestor.compute_signature(body, stale_ts)
    try:
        await ingestor.process_delivery(body, stale_ts, stale_sig, "dlv_stale")
    except WebhookRejected as exc:
        print(f"rejected expired delivery: {exc.reason} ({exc.status})")

    # 3. Valid: correct signature, fresh timestamp, new delivery id.
    delivery_id = "dlv_7f3a9c"
    work_order = await ingestor.process_delivery(body, now, good_sig, delivery_id)
    await enqueue_work_order(work_order)

    # 4. Replay: identical delivery id resent by a retrying sender or an attacker.
    try:
        await ingestor.process_delivery(body, now, good_sig, delivery_id)
    except WebhookRejected as exc:
        print(f"rejected replayed delivery: {exc.reason} ({exc.status})")


if __name__ == "__main__":
    asyncio.run(main())

Running it prints a rejection for the forged attempt, a rejection for the expired one, a single enqueued WO-91004 … line for the valid delivery, and a rejection for the replay — the same delivery id never enqueues twice. In production, swap the in-memory _seen_delivery_ids set for a Redis key with a TTL slightly longer than tolerance_s, and route the accepted WorkOrderPayload into async batch processing rather than calling enqueue_work_order synchronously.

Prevention Checklist

Frequently Asked Questions

Why sign the timestamp instead of just checking Age on the response like a cache-freshness check?

A webhook delivery has no response to inspect at signing time — the sender computes the signature once, before it ever reaches you. Binding the timestamp into the signed payload is the only way to make an old, otherwise-valid signature unusable after the tolerance window passes, since recomputing it would require the secret itself.

Isn’t checking the delivery id enough on its own, without a timestamp window?

No — an unbounded dedup set grows forever and still lets an attacker replay a new, never-before-seen delivery id with an old captured body and signature, if the id itself wasn’t part of what was signed. The timestamp window and the delivery-id check solve different problems: one bounds how long a signature stays valid, the other stops a second acceptance of the exact same delivery within that window.

What if the webhook sender doesn’t support a delivery id or timestamp header?

Push back on the integration before going live — a sender that can’t provide either is asking you to trust an unauthenticated, unbounded channel into your work order system. If you cannot change the sender, generate your own dedup key by hashing the raw body and require, at minimum, a Date header you can bound a tolerance window against.

Does constant-time comparison matter if the endpoint is only reachable over an internal network?

Yes. Network segmentation limits who can reach the endpoint, not how much information a reachable caller can extract from response timing. Any caller inside the trust boundary — including a compromised internal service — can still run a timing attack against a non-constant-time compare, so hmac.compare_digest is a baseline regardless of network placement.

This guide closes a trust gap inside webhook intake adapters; for the queue that receives verified deliveries see async batch processing, align the accepted shape with work order schema standards, restrict who can read or rotate the shared secret via security access boundaries, and compare this trust boundary with the parsing-side risks covered across work order ingestion and parsing pipelines.

Part of: Webhook Intake Adapters.