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
- Read the raw request body as bytes, before any JSON parsing.
- Read the timestamp and signature headers.
- Reject the request if the timestamp is more than a few minutes from your clock.
- Compute HMAC-SHA256 of
timestamp + "." + rawBodywith your secret. - Compare with the received signature using a constant-time comparison.
- Only then parse the JSON and process the event.
Verification function
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
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; usetimingSafeEqual. - 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.