DEV Community

Cover image for Signing webhooks so your receivers can actually trust them
Byteforge
Byteforge

Posted on

Signing webhooks so your receivers can actually trust them

Written for: developers who send or receive webhooks and want a simple, verifiable scheme.

Hey, fellow developer. Webhooks are easy to send and surprisingly easy to fake. Anyone who knows your receiver's URL can POST to it. So the sender needs to sign each message, and the receiver needs to check that signature before doing anything. This post shows how this boilerplate does it, with a few excerpts from the production module. A stripped-down, runnable version with tests is in the webhook-signing-sample repo. It uses X-Example-Signature instead of the production header name, so the code in the sample won't match the excerpts exactly.

The header

Every delivery carries one header:

X-CompanyName-Signature: t=<unix>,v1=<hex>

Two parts matter. t is the timestamp when the message was sent. v1 is an HMAC-SHA256 of the timestamp and the raw body, joined with a dot. Signing the timestamp is what lets receivers reject old messages.

Signing

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


def sign(secret: str, raw_body: bytes, timestamp: int) -> str:
    """Signature header value ``t=<unix>,v1=<hex>`` for ``raw_body`` sent at ``timestamp``."""
    return f"t={timestamp},v1={_digest(secret, raw_body, timestamp)}"
Enter fullscreen mode Exit fullscreen mode

Two details are easy to get wrong. The signature covers the raw bytes you sent, not the parsed JSON, so re-serializing the body on either side will break the check. And the timestamp is part of the signed string, so an attacker can't reuse a valid signature with a new time.

Secrets look like whsec_ followed by 32 random hex characters. The module also computes a short, non-secret prefix for display, so the UI can identify a secret without revealing it.

Verifying

The receiver's side is verify:

if abs((int(time.time()) if now is None else now) - timestamp) > tolerance:
    return False
expected = [_digest(secret, raw_body, timestamp) for secret in secrets]
return any(constant_time_equals(sig, exp) for sig in signatures for exp in expected)
Enter fullscreen mode Exit fullscreen mode

Three things happen here. First, the timestamp must be within 300 seconds of the receiver's clock, which stops replays of old captures. Second, the function accepts a list of secrets, not just one. Third, the comparison uses constant_time_equals, so an attacker can't learn the signature one character at a time from response timing.

Rolling secrets without downtime

Secrets get rotated, and you don't want a deploy to drop messages. The header comment describes the approach: during a roll, deliveries are signed with the new secret, while verification accepts any secret still inside its overlap window. That's why verify takes a list. Receivers that already have the new secret keep working, and receivers that still have the old one keep working until the window closes.

Putting it together

Here is the shape of a delivery, using the same helper the sender uses:

headers = delivery_headers(
    secret,
    event_id=event_id,
    raw_body=raw_body,
    timestamp=int(time.time()),
)
Enter fullscreen mode Exit fullscreen mode

It returns the content type, a user agent, the event ID, and the signature header. Setting test=True adds a header that marks the call as a test, so receivers can tell test traffic from live traffic.

A limit to know about

verify does not remember which signatures it has already accepted. Within the 300-second window, a captured message can be replayed and will still pass. For that reason, receivers should deduplicate on the X-CompanyName-Event-Id header and treat repeated events as no-ops. The signature stops forgery; deduplication stops replay.

If you want the full webhook pipeline, delivery retries, and the rest of the backend, the boilerplate includes it.

Get the full boilerplate: Byteforge

Top comments (0)