Skip to content
webhookssecurityHMAC

How to Verify Webhook Signatures with HMAC-SHA256

By the Finvercel Engineering teamPublished 6 min read

How webhook signatures work

The sender and receiver share a secret. For each delivery, the sender computes an HMAC — a keyed hash defined in RFC 2104 — over the timestamp and request body, and sends it in a header. The receiver recomputes the HMAC with the same secret; if the values match, the request is authentic and unmodified. Header names vary by provider, so check their documentation.

Step by step

  1. Read the raw request body as bytes, before any JSON parsing.
  2. Read the timestamp and signature headers.
  3. Reject the request if the timestamp is more than a few minutes from your clock.
  4. Compute HMAC-SHA256 of timestamp + "." + rawBody with your secret.
  5. Compare with the received signature using a constant-time comparison.
  6. Only then parse the JSON and process the event.

Verification function

verifyWebhook · Node.js
import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_SECONDS = 300 // reject events older than 5 minutes

export function signPayload(secret, timestamp, rawBody) {
  return createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')
}

export function verifyWebhook({ secret, rawBody, timestampHeader, signatureHeader, now = Date.now() }) {
  const timestamp = Number(timestampHeader)
  if (!Number.isInteger(timestamp)) return { ok: false, reason: 'missing or invalid timestamp' }
  if (Math.abs(now / 1000 - timestamp) > TOLERANCE_SECONDS) return { ok: false, reason: 'timestamp outside tolerance' }

  const expected = Buffer.from(signPayload(secret, timestamp, rawBody), 'hex')
  const received = Buffer.from(String(signatureHeader ?? ''), 'hex')
  // timingSafeEqual throws if lengths differ, so check first.
  if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
    return { ok: false, reason: 'signature mismatch' }
  }
  return { ok: true }
}

We tested this function with a valid signature (accepted), a modified body, the wrong secret, a timestamp one hour old, and a malformed signature — all four were rejected.

Using it in an Express endpoint

Express route · Node.js
import express from 'express'

const app = express()

// Keep the raw bytes: re-serializing parsed JSON can change whitespace or key order and break the signature.
app.post('/webhooks/provider', express.raw({ type: 'application/json' }), (req, res) => {
  const result = verifyWebhook({
    secret: process.env.WEBHOOK_SECRET,
    rawBody: req.body.toString('utf8'),
    timestampHeader: req.get('Webhook-Timestamp'),
    signatureHeader: req.get('Webhook-Signature'),
  })
  if (!result.ok) return res.status(400).send(result.reason)

  const event = JSON.parse(req.body)
  queue.enqueue(event)      // do slow work asynchronously
  res.sendStatus(200)       // acknowledge quickly
})

Common mistakes

  • Verifying parsed JSON: whitespace and key order change when you re-serialize, so the HMAC no longer matches.
  • Using === to compare signatures: ordinary string comparison can leak timing information; use timingSafeEqual.
  • Skipping the timestamp check: without it, captured requests can be replayed indefinitely.
  • Hard-coding the secret: load it from an environment variable or secret manager, and rotate it if exposed.

Security considerations

Signature verification proves authenticity, not that an event is new. Combine it with idempotent processing (see webhooks vs. polling) so retried deliveries are not applied twice.

Related questions

Why does signature verification fail even with the right secret?

The most common cause is verifying a parsed-and-re-serialized body instead of the exact raw bytes received. Capture the raw body before any JSON parsing.

Why include a timestamp in the signature?

It lets you reject old requests, so an attacker who captured a valid webhook cannot replay it later.

Is HTTPS enough on its own?

No. HTTPS protects data in transit, but anyone can send a request to a public endpoint. The signature proves the request came from a holder of the shared secret.

References

Related reading

This article is general technical information. Check the documentation of the specific APIs you integrate with, as behaviour varies between providers.

Building an integration?

Get help from the Finvercel team.

Tell us which systems you need to connect and we will explain the recommended approach.