This document describes the security properties of the LedgerLens webhook system, covering HMAC signing, replay prevention, secret rotation, dead-letter recovery, and SSRF protection. For a consolidated system threat model, see the STRIDE Threat Model.
Every webhook delivery is signed with HMAC-SHA256 using the subscriber's secret key.
The signature is sent in the X-LedgerLens-Signature header:
X-LedgerLens-Signature: sha256=<hex-digest>
The digest is computed over the raw request body bytes (not the parsed JSON). Receivers must verify this signature before trusting the payload:
import hmac, hashlib
def verify_ledgerlens_webhook(body: bytes, secret: str, signature: str) -> bool:
if not signature.startswith("sha256="):
return False
expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
# MUST use compare_digest — never use == (timing side-channel)
return hmac.compare_digest(signature, expected)Always use hmac.compare_digest, never ==, to avoid timing side-channel attacks.
Each delivery includes a X-LedgerLens-Timestamp header containing the Unix epoch second when the delivery was attempted.
Receivers should reject payloads whose timestamp falls outside a ±5 minute (300 second) window:
import time
def verify_timestamp(ts: int, window_seconds: int = 300) -> bool:
now = int(time.time())
return abs(now - ts) <= window_secondsThis prevents a valid signed payload from being replayed hours or days later.
- Register a new subscriber with the new secret via
POST /webhooks. - Update your receiver to accept both old and new secrets (dual-verification) during the cutover.
- Deactivate the old subscriber via
DELETE /webhooks/{old_subscriber_id}. - Once all in-flight deliveries for the old subscriber have completed or dead-lettered, remove dual-verification from your receiver.
Deliveries enqueued against the new subscriber_id will always be signed with the new secret — there is no ambiguity once the old subscriber is deactivated.
After 8 consecutive delivery failures, an item moves to dead status. Inspect dead-lettered items via:
GET /webhooks/dead-lettersTo recover, fix the underlying issue (endpoint unreachable, invalid response) and re-enqueue the affected payloads manually or through the dispute/governance workflow.
Subscriber URLs are validated at registration time:
- Only
https://scheme is accepted (HTTP is rejected). - Hostnames are resolved via DNS; private/reserved IP ranges are rejected:
127.x.x.x/localhost/::1(loopback)10.x.x.x(RFC 1918)172.16–31.x.x(RFC 1918)192.168.x.x(RFC 1918)fc00::/7(IPv6 ULA)0.0.0.0
This prevents the delivery worker from being used as a proxy to reach internal services (SSRF).
The Go SDK (go/) ships an equivalent helper using hmac.Equal (constant-time), which directly mirrors the Python hmac.compare_digest reference above:
import (
ledgerlens "github.com/Ledger-Lenz/Ledgerlens-core/go"
)
// In your webhook HTTP handler:
ok := ledgerlens.VerifyWebhookSignature(body, webhookSecret, r.Header.Get("X-LedgerLens-Signature"))
if !ok {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
ok = ledgerlens.VerifyWebhookTimestamp(
r.Header.Get("X-LedgerLens-Timestamp"),
ledgerlens.DefaultWebhookMaxAge, // 5 * time.Minute
)
if !ok {
http.Error(w, "timestamp too old", http.StatusUnauthorized)
return
}VerifyWebhookSignature uses hmac.Equal — never == or bytes.Equal. The Go and Python helpers produce identical digests for the same (secret, body) input; this is verified in go/webhook_test.go's TestVerifyWebhookSignature_CrossCheckPythonScheme test.
See go/README.md for installation instructions.
tests/test_webhook_security.py provides exhaustive coverage:
| Test class | What it verifies |
|---|---|
TestHMACVerification |
Correct/wrong secrets, tampered body, wrong prefix |
TestTimestampReplayPrevention |
All boundary conditions of the 5-minute window |
TestSecretRotation |
New secret used after rotation; no duplicates |
TestDeadLetterBehaviour |
Exactly 8 failures; exponential backoff formula |
TestConcurrency |
10 parallel deliveries; slow subscriber isolation |
TestSSRFProtection |
All private IP ranges; HTTP scheme |
| Static analysis | webhook_worker.py uses hmac.compare_digest not == |