This article outlines critical considerations for securely verifying webhook signatures, emphasizing the importance of authenticating the raw request body before any parsing. It highlights common pitfalls like object key order changes and whitespace differences that can invalidate signatures, and discusses architectural decisions related to credential management, idempotency, and the secure processing flow within an ingress handler.
Read original on Dev.to #architectureA fundamental principle for secure webhook signature verification is that the bytes used for verification must be *identical* to the bytes signed by the sender. Early parsing of the JSON payload can introduce subtle changes (e.g., in object key order, whitespace, numeric rendering, or escaping) that cause a legitimate signature to fail verification. This makes the system vulnerable to replay attacks or incorrect event processing. Therefore, the raw request body should be preserved and used directly for HMAC computation.
Security Consequence of Early Parsing
Authenticating a webhook against a modified representation of its payload is a significant security flaw. While a malformed event might be rejected and re-sent, accepting an unauthentic event due to parsing discrepancies can lead to much harder-to-unwind issues, such as incorrect billing in a metered account platform.
The ideal sequence for processing webhooks, especially in frameworks like Node.js Express, involves careful middleware ordering to ensure the raw body is accessible at the right stage. The flow should strictly enforce: raw bytes acquisition -> header validation -> key lookup -> signature comparison (constant time) -> JSON parsing (only after success) -> schema validation -> idempotent ledger write. Any deviation, particularly parsing before signature verification, compromises security.
import hashlib
import hmac
import json
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class VerifiedUsageEvent:
sender_id: str
event_id: str
customer_id: str
units: int
def verify_and_decode(
body: bytes,
signature_header: str,
sender_id: str,
secret_by_sender: dict[str, bytes],
) -> VerifiedUsageEvent:
prefix = "v1="
if not signature_header.startswith(prefix):
raise PermissionError("missing supported signature version")
supplied_hex = signature_header[len(prefix):]
try:
supplied = bytes.fromhex(supplied_hex)
except ValueError as exc:
raise PermissionError("malformed signature") from exc
secret = secret_by_sender.get(sender_id)
if secret is None:
raise PermissionError("unknown sender")
expected = hmac.digest(secret, body, hashlib.sha256)
if not hmac.compare_digest(expected, supplied):
raise PermissionError("signature mismatch")
try:
payload: dict[str, Any] = json.loads(body)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError("authenticated body is not valid JSON") from exc
return VerifiedUsageEvent(
sender_id=sender_id,
event_id=str(payload["event_id"]),
customer_id=str(payload["customer_id"]),
units=int(payload["units"]),
)This Python example illustrates the critical path: the raw `body` (bytes) is used directly with `hmac.digest` for signature comparison, and JSON parsing only occurs after successful authentication. This ensures the integrity of the data being verified matches the sender's signed payload.