pull/723/merge
Hemanth Chittanuru 6 days ago committed by GitHub
commit d775b9cb86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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…
Cancel
Save