-
Notifications
You must be signed in to change notification settings - Fork 0
HMAC Verification
If WEBHOOK_SECRET is set, every incoming request must include a valid HMAC-SHA256 signature. The verifier handles three header names:
-
X-Signature(generic, default) -
X-Hub-Signature-256(GitHub style) -
X-Stripe-Signature(Stripe specific)
And two value formats:
-
sha256=<hex>(with prefix) -
<hex>(raw)
const expected = HMAC_SHA256(rawBody, WEBHOOK_SECRET)
const provided = stripPrefix(header, 'sha256=')
return timingSafeEqual(expected, provided)Constant-time comparison via crypto.timingSafeEqual. No early bailout on length mismatch (which would allow timing attacks).
- Webhooks dashboard → Add endpoint → URL
/hooks/stripe - Click the new endpoint → "Reveal signing secret"
- Set that string as
WEBHOOK_SECRETin.env
Stripe sends Stripe-Signature: t=<timestamp>,v1=<sig>,v0=<sig>. The current verifier reads the simple <hex> or sha256=<hex> part. For Stripe's full timestamped scheme (which prevents replay), use Stripe's official library:
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY)
app.post('/hooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature']
let event
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.WEBHOOK_SECRET)
} catch (e) {
return res.status(401).send(`Webhook Error: ${e.message}`)
}
// process event
})This is more strict (rejects old replays) and recommended for Stripe specifically.
- Repo Settings → Webhooks → Add webhook
- Set Content type to
application/json - Set Secret to your
WEBHOOK_SECRET - Choose events
GitHub sends X-Hub-Signature-256: sha256=<hex>. The verifier handles this directly.
- cal.com → Workflows → Webhooks
- Add webhook with URL
/hooks/cal - Set "Secret" to your
WEBHOOK_SECRET
Cal.com sends X-Cal-Signature-256: <hex>. To support that header, add it to the verifier:
const headerRaw = req.get('X-Signature')
|| req.get('X-Hub-Signature-256')
|| req.get('X-Stripe-Signature')
|| req.get('X-Cal-Signature-256')- Linear → Settings → API → Webhooks
- Add URL, copy the signing secret to
WEBHOOK_SECRET
Linear sends Linear-Signature: <hex>. Same fix as Cal.com: add the header to the list.
Use the default X-Signature header. Sender side:
const signature = crypto
.createHmac('sha256', SHARED_SECRET)
.update(JSON.stringify(payload))
.digest('hex')
await fetch('https://your-domain.com/hooks/internal', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Signature': `sha256=${signature}`,
},
body: JSON.stringify(payload),
})Check:
- The
WEBHOOK_SECRETin.envmatches what's in the provider's webhook config - The provider is actually sending the signature header (some providers don't unless you check a box)
- There's no whitespace or quotes around the secret in
.env
Almost always: the production env doesn't have WEBHOOK_SECRET set, or has a different value.
# Check on Vercel
vercel env ls
# Check on Fly
fly secrets list
# Check on Render
render env listBody parsing buffer issue. The verifier uses req.rawBody captured at parse time. If you swapped the JSON parser, the raw body capture might not happen for streaming bodies. Stick with the express.json({ verify }) pattern in src/index.js.
Stripe's signed timestamp tolerance is 5 minutes by default. If your server clock drifts, signatures fail. Run ntpd or rely on your platform's time sync.
If your source doesn't support HMAC at all (some webhooks just don't), don't set WEBHOOK_SECRET. The endpoint becomes open. Mitigations:
- Use a cryptic URL:
/hooks/8a7b6c5d4e3f(effectively a shared secret in the URL) - Add Cloudflare Access or your platform's auth in front
- Restrict source IPs to the provider's published webhook IP ranges (Stripe publishes theirs)