Skip to content

HMAC Verification

sarmakska edited this page May 31, 2026 · 2 revisions

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.

Provider profiles

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.

How it works

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.

Per-provider setup

Stripe

  1. Stripe dashboard, Developers, Webhooks, Add endpoint, URL /hooks/stripe.
  2. Reveal the signing secret and set it as WEBHOOK_SECRET.
  3. The stripe profile validates the timestamped Stripe-Signature header directly, including the tolerance window. No SDK required.

GitHub

  1. Repo Settings, Webhooks, Add webhook.
  2. Content type application/json, Secret set to your WEBHOOK_SECRET, URL /hooks/github.
  3. GitHub signs with X-Hub-Signature-256, read by the github profile.

Cal.com

  1. Cal.com, Settings, Webhooks, add a webhook with URL /hooks/cal.
  2. Set the secret to your WEBHOOK_SECRET.
  3. Cal.com signs with X-Cal-Signature-256, read by the cal profile.

Linear

  1. Linear, Settings, API, Webhooks.
  2. Add the URL /hooks/linear and copy the signing secret to WEBHOOK_SECRET.
  3. Linear signs with Linear-Signature, read by the linear profile.

Generic or internal services

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.

Common failures

401 on every request

  1. The WEBHOOK_SECRET does not match the provider's webhook config.
  2. The provider is not sending the signature header (some require enabling it).
  3. There is whitespace or quotes around the secret in .env.
  4. You are hitting the wrong source path, so the wrong provider profile runs.

Works in dev, fails in production

Usually the production environment has no WEBHOOK_SECRET, or a different value. Check with fly secrets list, vercel env ls or your platform equivalent.

Stripe signature occasionally fails

The timestamp tolerance defaults to 300 seconds. If the server clock drifts beyond that, signatures fail. Keep the host clock synced.

Large payloads fail to verify

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.

Clone this wiki locally