-
Notifications
You must be signed in to change notification settings - Fork 0
HMAC Verification
If WEBHOOK_SECRET is set, every incoming request must carry a valid HMAC-SHA256 signature. Verification is per-provider: the :source segment selects a profile that knows which header carries the signature and how to validate it. All comparisons run in constant time via crypto.timingSafeEqual, with a length check first so the buffers can be compared safely.
| Source | Header read | Scheme |
|---|---|---|
stripe |
Stripe-Signature |
t=<ts>,v1=<sig> over <ts>.<rawBody>, with timestamp tolerance |
github |
X-Hub-Signature-256 |
sha256=<hex> over the raw body |
cal |
X-Cal-Signature-256 |
<hex> over the raw body |
linear |
Linear-Signature |
<hex> over the raw body |
| anything else | X-Signature |
sha256=<hex> or <hex> over the raw body |
The generic profile also falls back to reading X-Hub-Signature-256 and Stripe-Signature, so an unknown source that happens to send one of those still verifies with a shared secret.
For the generic, GitHub, Cal.com and Linear profiles:
expected = hex(hmac_sha256(rawBody, WEBHOOK_SECRET))
provided = stripPrefix(header, "sha256=")
accept if timingSafeEqual(provided, expected)
For Stripe, the scheme is implemented directly so there is no dependency on the Stripe SDK:
parse t=<ts>, v1=<sig> from Stripe-Signature
reject if abs(now - ts) > toleranceSeconds // default 300
expected = hex(hmac_sha256(WEBHOOK_SECRET, ts + "." + rawBody))
accept if timingSafeEqual(v1, expected)
The timestamp check is what gives Stripe replay protection: a captured request stops verifying once it ages past the tolerance window.
- Stripe dashboard, Developers, Webhooks, Add endpoint, URL
/hooks/stripe. - Reveal the signing secret and set it as
WEBHOOK_SECRET. - The stripe profile validates the timestamped
Stripe-Signatureheader directly, including the tolerance window. No SDK required.
- Repo Settings, Webhooks, Add webhook.
- Content type
application/json, Secret set to yourWEBHOOK_SECRET, URL/hooks/github. - GitHub signs with
X-Hub-Signature-256, read by the github profile.
- Cal.com, Settings, Webhooks, add a webhook with URL
/hooks/cal. - Set the secret to your
WEBHOOK_SECRET. - Cal.com signs with
X-Cal-Signature-256, read by the cal profile.
- Linear, Settings, API, Webhooks.
- Add the URL
/hooks/linearand copy the signing secret toWEBHOOK_SECRET. - Linear signs with
Linear-Signature, read by the linear profile.
Use the default X-Signature header. Sender side:
const crypto = require('crypto')
const signature = crypto.createHmac('sha256', SHARED_SECRET).update(body).digest('hex')
await fetch('https://your-domain.com/hooks/internal', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Signature': `sha256=${signature}` },
body, // the exact string you signed
})Sign the exact bytes you send. The verifier checks against req.rawBody, the raw bytes captured before JSON parsing.
- The
WEBHOOK_SECRETdoes not match the provider's webhook config. - The provider is not sending the signature header (some require enabling it).
- There is whitespace or quotes around the secret in
.env. - You are hitting the wrong source path, so the wrong provider profile runs.
Usually the production environment has no WEBHOOK_SECRET, or a different value. Check with fly secrets list, vercel env ls or your platform equivalent.
The timestamp tolerance defaults to 300 seconds. If the server clock drifts beyond that, signatures fail. Keep the host clock synced.
The verifier uses req.rawBody captured at parse time by the express.json({ verify }) hook. If you change the body parser, make sure the raw body capture still runs.