mirror of https://github.com/docusealco/docuseal
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
125 lines
4.2 KiB
125 lines
4.2 KiB
# 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.
|