From bb6cfcf7ab821ec56fea69fda20038883798256e Mon Sep 17 00:00:00 2001 From: Hemanth Chittanuru Date: Sun, 13 Sep 2026 00:32:31 -0500 Subject: [PATCH] docs: document webhook signature verification Webhook signing has shipped since 45ae954c, but what the HMAC is computed over is only readable in lib/webhook_urls/signatures.rb. The UI hint gives the header and its shape (.) without saying that the digest covers "{timestamp}.{body}" rather than the body alone, so a verifier written from the hint computes a digest that can never match. Documents the header format, the 5-minute tolerance, verifying against the raw body before parsing, and constant-time comparison, with Ruby, Python and Node examples. Also covers the two things only discoverable from source: that the Security modal's Secret tab is a custom header rather than the signing secret, and that naming a custom header X-Docuseal-Signature overrides the real one. --- docs/webhooks/webhook-signature.md | 124 +++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/webhooks/webhook-signature.md diff --git a/docs/webhooks/webhook-signature.md b/docs/webhooks/webhook-signature.md new file mode 100644 index 00000000..6d71a106 --- /dev/null +++ b/docs/webhooks/webhook-signature.md @@ -0,0 +1,124 @@ +# Webhook Signature + +Every webhook request can be signed with an HMAC signature so your endpoint can verify it came +from DocuSeal and was not modified in transit. + +## Enabling signatures + +Open **Settings → Webhooks**, click **Security** on a webhook URL, and select the **HMAC** tab. +The **HMAC Signing Secret** shown there is generated by DocuSeal for that webhook URL — it is +read-only, so there is no value for you to choose. Copy it into your application's configuration. + +The **Secret** tab beside it is a different feature: a custom request header (for example +`X-Signature: my-value`) that DocuSeal sends verbatim. It is not used to compute the signature +below. + +## The signature header + +Signed requests carry: + +``` +X-Docuseal-Signature: . +``` + +- `` is the Unix time in seconds at which the request was signed. +- `` is the lowercase hex HMAC-SHA256 digest of the string `"."` — + the timestamp, a literal `.`, then the **raw request body** — keyed with the HMAC signing + secret. + +Note that the digest covers the timestamp-prefixed body, not the body alone. + +## Verifying a request + +1. Split the header on the first `.` into `timestamp` and `signature`. +2. Reject the request if the timestamp is more than **5 minutes** away from your current time, in + either direction. This is what stops a captured request from being replayed later. +3. Recompute `HMAC-SHA256(secret, "#{timestamp}.#{body}")` over the **raw body bytes**, exactly as + received. +4. Compare it to `signature` using a constant-time comparison. + +Verify before parsing. Re-serializing the JSON — even with identical keys — can change whitespace +or key order and will produce a different digest. + +### Ruby + +```ruby +def verify_docuseal_signature(secret, body:, header:, tolerance: 300) + timestamp, signature = header.to_s.split('.', 2) + timestamp = Integer(timestamp, exception: false) + return false unless timestamp && signature + return false if (Time.now.to_i - timestamp).abs > tolerance + + expected = OpenSSL::HMAC.hexdigest('sha256', secret, "#{timestamp}.#{body}") + + ActiveSupport::SecurityUtils.secure_compare(expected, signature) +end +``` + +### Python + +```python +import hashlib +import hmac +import time + + +def verify_docuseal_signature(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool: + timestamp, _, signature = header.partition(".") + if not timestamp or not signature: + return False + try: + signed_at = int(timestamp) + except ValueError: + return False + if abs(time.time() - signed_at) > tolerance: + return False + + expected = hmac.new( + secret.encode(), + f"{timestamp}.".encode() + body, + hashlib.sha256, + ).hexdigest() + + return hmac.compare_digest(expected, signature) +``` + +### Node.js + +```javascript +const crypto = require('crypto') + +function verifyDocusealSignature(secret, body, header, tolerance = 300) { + const [timestamp, signature] = String(header).split(/\.(.*)/s) + if (!timestamp || !signature) return false + + const signedAt = Number(timestamp) + if (!Number.isFinite(signedAt)) return false + if (Math.abs(Date.now() / 1000 - signedAt) > tolerance) return false + + const expected = crypto + .createHmac('sha256', secret) + .update(`${timestamp}.`) + .update(body) + .digest('hex') + + const a = Buffer.from(expected) + const b = Buffer.from(signature) + + return a.length === b.length && crypto.timingSafeEqual(a, b) +} +``` + +In each example `body` is the raw request body — `request.body.read` in Rack, `await +request.body()` in FastAPI, `express.raw({ type: 'application/json' })` in Express. + +## Notes + +- The signing secret is **per webhook URL**. A second webhook URL has its own secret. +- Every webhook URL is given a signing secret when it is created, so every request is signed — + there is nothing to turn on. +- If you add a custom header on the **Secret** tab and name it `X-Docuseal-Signature`, your value + is sent and the real signature is not. Name it something else. +- Return a `4xx` when verification fails rather than `200`. A response of `400` or above marks the + event as errored and is retried with exponential backoff (up to 10 attempts), so a failing + signature stays visible instead of being silently accepted.