mirror of https://github.com/docusealco/docuseal
Merge bb6cfcf7ab into 6e2430e5a2
commit
d775b9cb86
@ -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