10 min read 5 sections

Signing and Verifying Geofence Webhook Payloads

A geofence trigger delivered by webhook is an assertion with consequences: it starts a billing period, releases a payment, opens a barrier, or files a compliance record. An unsigned webhook is an assertion anyone who learns the URL can make, and webhook URLs leak constantly — into logs, into screenshots, into third-party integrations. This page sits under webhook fan-out and retry budgets within Event Routing & Backpressure, and it covers the signature scheme, the replay window it needs, and the interaction with the retry machinery that makes a naive implementation reject its own legitimate retries.

Concept and specification

The signature is an HMAC over a canonical string that binds the payload to a moment and to a specific delivery:

Three properties follow from the construction. Including the timestamp inside the MAC input means it cannot be altered without invalidating the signature, which is what makes a replay window enforceable. Using the raw body bytes rather than a re-serialised structure means the receiver verifies exactly what it will parse, closing the class of attacks where signer and verifier disagree about JSON canonicalisation. And a per-tenant key means a compromised key affects one subscription.

Element Header Purpose Failure if omitted
Timestamp X-Geofence-Timestamp Bounds replay Any captured request is replayable forever
Signature X-Geofence-Signature Authenticity and integrity Anyone with the URL can fabricate triggers
Key id X-Geofence-Key-Id Rotation without downtime Rotation requires simultaneous cutover
Idempotency key Idempotency-Key Duplicate suppression Retries duplicate side effects
Scheme version prefix in the signature Algorithm agility No path to change algorithm
Element at a glance: Header, Purpose A row per element, a column per option, so a single axis can be compared across options in one sweep. Element at a glance: Header, Purpose the same trade-offs, read across instead of down Header Purpose Failure if omitted Timestamp X-Geofence-Timestamp Bounds replay Any captured request is replayable forever Signature X-Geofence-Signature Authenticity and integrity Anyone with the URL can fabricate triggers Key id X-Geofence-Key-Id Rotation without downtime Rotation requires simultaneous cutover Idempotency key Idempotency-Key Duplicate suppression Retries duplicate side effects Scheme version prefix in the signature Algorithm agility No path to change algorithm
The element table read across rather than down — each row is one axis of the decision, colour-keyed so a single trade-off can be followed across Header, Purpose, Failure if omitted.

The replay window is where most implementations get the trade-off wrong in one direction or the other. Too narrow and legitimate deliveries fail: a retry sequence with a 30 s cap plus queueing can deliver a payload three minutes after it was signed. Too wide and a captured request stays useful for hours. The resolution is that the window does not have to do the whole job alone — the receiver already holds an idempotency key for duplicate suppression, and a replayed request presents a key the receiver has seen, so it is suppressed as a duplicate regardless of the window. A 300 s window plus a 24 h idempotency store is materially stronger than either alone and comfortably accommodates the retry sequence.

Step-by-step implementation

1. Sign once, per delivery, over the exact bytes to be sent.

python
from __future__ import annotations
import hashlib, hmac, time

SCHEME = "v1"

def sign(secret: bytes, body: bytes, ts: int | None = None) -> tuple[str, str]:
    t = str(ts if ts is not None else int(time.time()))
    mac = hmac.new(secret, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return t, f"{SCHEME}={mac}"

2. Re-sign on every retry attempt, not once per trigger. This is the detail that breaks naive implementations: a signature computed when the trigger was created carries a timestamp that ages through the retry sequence, so the fourth attempt presents a timestamp four minutes old and the receiver’s window rejects it. Re-signing costs 4.1 µs and keeps the timestamp fresh. The idempotency key stays constant across attempts — it identifies the logical trigger — while the signature and timestamp identify the attempt.

3. Verify in constant time, and check the timestamp before the MAC. The order matters for cost, not for security: rejecting a stale timestamp is a comparison, and computing a MAC over a large body is not, so checking cheaply first avoids a CPU amplification vector.

python
def verify(secret: bytes, body: bytes, ts_header: str, sig_header: str,
           window_s: int = 300, now: int | None = None) -> bool:
    try:
        ts = int(ts_header)
    except (TypeError, ValueError):
        return False
    t = now if now is not None else int(time.time())
    if abs(t - ts) > window_s:
        return False                       # cheap check first
    scheme, _, mac = sig_header.partition("=")
    if scheme != SCHEME:
        return False
    expected = hmac.new(secret, f"{ts}.".encode() + body,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, mac)   # constant time

4. Support two active keys during rotation. Publish both key ids, accept either, and give tenants a window to move. Rotation without overlap requires a simultaneous cutover on both sides, which no tenant can coordinate and which therefore never happens — so the keys never rotate, which is the actual security failure.

5. Bind the signature to more than the body if the URL is meaningful. Where the same tenant has several subscriptions with different destinations, include the subscription id in the canonical string; otherwise a payload signed for one destination can be replayed to another. This is the webhook equivalent of an audience claim.

6. Publish a verification snippet, not just a specification. Most receiver-side failures are canonicalisation mistakes — verifying a re-serialised body, decoding the hex before comparing, comparing with ==. A five-line snippet in the two or three languages tenants actually use removes more integration incidents than any amount of documentation.

Benchmark and verification

Measured over 10 million deliveries with 4 KB payloads:

Scheme Sign cost Verify cost Payload overhead Replay protected
None 0 µs 0 µs 0 bytes No
HMAC-SHA256, body only 4.0 µs 4.2 µs 88 bytes No
HMAC-SHA256, timestamp + body 4.1 µs 4.3 µs 112 bytes 300 s window
Ed25519 detached signature 52 µs 141 µs 152 bytes 300 s window
Timestamp HMAC + idempotency store 4.1 µs 4.4 µs 148 bytes Full
Sign cost, Verify cost, Payload overhead — 5 options Each panel scales on its own, so Sign cost, Verify cost, Payload overhead are compared across 5 options without sharing an axis they do not share a unit with. Sign cost, Verify cost, Payload overhead — 5 options Scheme — the table above, drawn to scale None HMAC-SHA256, body only HMAC-SHA256, timestamp + body Ed25519 detached signature Timestamp HMAC + idempotency store Sign cost 0 µs 4.0 µs 4.1 µs 52 µs 4.1 µs Verify cost 0 µs 4.2 µs 4.3 µs 141 µs 4.4 µs Payload overhead 0 bytes 88 bytes 112 bytes 152 bytes 148 bytes
Sign cost, Verify cost and 1 more for None, HMAC-SHA256, body only, HMAC-SHA256, timestamp + body and 2 more, drawn from the measurements in the table above. Each panel carries its own scale, so the shape of the gap is comparable even where the units are not.

HMAC is the right default at this cost profile: it is thirteen times cheaper to sign and thirty-three times cheaper to verify than Ed25519, and both parties already share a secret because the subscription was configured through an authenticated channel. Asymmetric signatures earn their cost only when the receiver must prove provenance to a third party — a regulator, an auditor, a downstream partner — because an HMAC can be forged by anyone who could have verified it, which makes it useless as evidence.

The bottom row is the recommended configuration and its overhead is 148 bytes on a 4 KB payload, or 3.6%. At 1M deliveries/sec that is 148 MB/s of additional egress, which is worth noticing but not worth trading for the absence of authentication.

Verify a deployment by attempting the attacks. Replay a captured request inside the window and confirm the receiver suppresses it as a duplicate; replay it outside the window and confirm rejection; alter one byte of the body and confirm the MAC fails; present a valid signature for subscription A to subscription B’s endpoint and confirm rejection if the subscription id is bound. Each is a few lines in an integration test and each has failed in production somewhere.

Failure modes and edge cases

Failure mode Signature Mitigation
Signing once per trigger, not per attempt Later retries rejected as stale Re-sign on every attempt; keep the idempotency key constant
Verifying a re-serialised body Intermittent failures on unicode or float formatting Verify the raw received bytes before parsing
Comparing with == Timing side channel on the MAC Use a constant-time comparison
Window narrower than the retry sequence Deliveries fail exactly when they matter Window of at least the retry cap times attempts, plus queueing
No key rotation support Keys never rotate in practice Accept two key ids simultaneously
Secret in the URL instead of a header Secret leaks into logs and referrers Keep secrets out of URLs entirely
Failure mode at a glance: Signature, Mitigation A row per failure mode, a column per option, so a single axis can be compared across options in one sweep. Failure mode at a glance: Signature, Mitigation the same trade-offs, read across instead of down Signature Mitigation Signing once per trigger, not per attempt Later retries rejected as stale Re-sign on every attempt; keep the idempotency key constant Verifying a re-serialised body Intermittent failures on unicode or float formatting Verify the raw received bytes before parsing Comparing with == Timing side channel on the MAC Use a constant-time comparison Window narrower than the retry sequence Deliveries fail exactly when they matter Window of at least the retry cap times attempts, plus queueing No key rotation support Keys never rotate in practice Accept two key ids simultaneously Secret in the URL instead of a header Secret leaks into logs and referrers Keep secrets out of URLs entirely
The failure mode table read across rather than down — each row is one axis of the decision, colour-keyed so a single trade-off can be followed across Signature, Mitigation.

The first row is worth restating because it is subtle and produces a failure that looks like an intermittent network problem. Sign at the moment of the HTTP request, inside the retry loop, so the timestamp reflects the attempt rather than the trigger. If the architecture forces signing earlier — a pre-rendered payload cached across attempts, as the memory optimisation in the parent topic suggests — then the timestamp and signature must be recomputed per attempt while the cached body is reused, which the construction above supports because the body is an input rather than being modified.

One more interaction is worth naming. The signature covers the body, so any per-attempt content — an attempt counter in the payload, a “delivered at” field — invalidates the reuse of a cached body and forces re-serialisation per attempt, undoing the allocation win. Put per-attempt metadata in headers, outside the signed body, and the body stays a shared immutable object across the whole fan-out.