Skip to content

HMAC Verification

sarmakska edited this page May 3, 2026 · 2 revisions

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)

How it works

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).

Per-provider setup

Stripe

  1. Webhooks dashboard → Add endpoint → URL /hooks/stripe
  2. Click the new endpoint → "Reveal signing secret"
  3. Set that string as WEBHOOK_SECRET in .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.

GitHub

  1. Repo Settings → Webhooks → Add webhook
  2. Set Content type to application/json
  3. Set Secret to your WEBHOOK_SECRET
  4. Choose events

GitHub sends X-Hub-Signature-256: sha256=<hex>. The verifier handles this directly.

Cal.com

  1. cal.com → Workflows → Webhooks
  2. Add webhook with URL /hooks/cal
  3. 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

  1. Linear → Settings → API → Webhooks
  2. 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.

Generic / internal services

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),
})

Common verification failures

"Invalid signature" on every request

Check:

  1. The WEBHOOK_SECRET in .env matches what's in the provider's webhook config
  2. The provider is actually sending the signature header (some providers don't unless you check a box)
  3. There's no whitespace or quotes around the secret in .env

Works in dev, fails in production

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 list

Signature works for small payloads, fails for large ones

Body 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 signature occasionally fails

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.

When NOT to use HMAC

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)

Clone this wiki locally