mirror of https://github.com/docusealco/docuseal
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 (<timestamp>.<sha256>) 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.
pull/723/head
parent
11856a17ac
commit
bb6cfcf7ab
@ -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: <timestamp>.<sha256>
|
||||
```
|
||||
|
||||
- `<timestamp>` is the Unix time in seconds at which the request was signed.
|
||||
- `<sha256>` is the lowercase hex HMAC-SHA256 digest of the string `"<timestamp>.<body>"` —
|
||||
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.
|
||||
Loading…
Reference in new issue