Webhook signature verification: HMAC-SHA256 done right
Webhook signature verification explained: how HMAC-SHA256 signatures work, a correct Node.js example, replay protection and the mistakes that let forgeries in.
- Published
- Reading time
- 6 min read
- By
- CentraPoint Team
On this page
Webhook signature verification is how your server proves that an incoming webhook really came from your payment provider and wasn't altered in transit. The provider computes a signature over the request, usually an HMAC-SHA256 of the raw body using a secret only you and it know; you recompute it on receipt and compare the two in constant time. If they don't match, you reject the request and do nothing else.
Because a payment webhook can mark an invoice paid or release an order, skipping or botching this check is one of the most costly mistakes in a payment integration.
Why webhooks need signatures
Your webhook endpoint is a public URL. Anyone who discovers it can send a request that looks exactly like "payment successful for order 1042". Without verification, your system would believe it.
Signatures solve two problems:
- Authenticity. Only someone holding the shared secret can produce a valid signature.
- Integrity. Changing even one character of the body produces a completely different signature.
What signatures don't solve on their own is replay: an attacker who captures a genuine signed request could resend it. That's why many providers also sign a timestamp, and why you should process events idempotently.
How HMAC-SHA256 signing works
HMAC (hash-based message authentication code) combines a secret key with a message using a hash function, here SHA-256. The provider does:
- Take the exact bytes of the request body (sometimes prefixed with a timestamp or other fields, as documented).
- Compute
HMAC-SHA256(secret, message). - Encode the result, usually as hex or Base64, and send it in a request header.
You repeat steps 1 and 2 with your copy of the secret and compare. The exact message format, encoding and header name differ between providers, so always follow the provider's documentation precisely.
A correct Node.js example
const crypto = require("crypto");
// rawBody must be the exact bytes received (a Buffer), not re-serialised JSON.
function isValidSignature(rawBody, receivedHex, secret) {
if (typeof receivedHex !== "string") return false;
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest();
const received = Buffer.from(receivedHex, "hex");
// timingSafeEqual throws if lengths differ, so check length first.
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
And wiring it into Express so the raw body is preserved:
const express = require("express");
const app = express();
// SIGNATURE_HEADER is whatever header name your provider documents.
app.post("/webhooks/payments", express.raw({ type: "application/json" }), (req, res) => {
if (!isValidSignature(req.body, req.get(SIGNATURE_HEADER), process.env.WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
// Store the event, acknowledge, then process asynchronously.
res.status(200).end();
});
If your provider sends a Base64 signature instead of hex, decode the received value with Buffer.from(value, "base64") and compare those bytes to the raw digest in the same way.
Five mistakes that break verification
1. Verifying parsed and re-serialised JSON
If a framework parses the body and you call JSON.stringify on the result, whitespace, key order and number formatting can change. The signature was computed over the original bytes, so verification fails, or worse, developers "fix" it by turning verification off. Capture the raw body before any parser touches it.
2. Comparing with ===
Normal string comparison stops at the first differing character, which can leak timing information. Use a constant-time comparison such as crypto.timingSafeEqual in Node.js or hmac.compare_digest in Python.
3. Ignoring encoding
Hex and Base64 representations of the same digest are different strings. Decode the received signature to bytes using the provider's encoding before comparing.
4. Hard-coding or leaking the secret
Keep the signing secret in environment variables or a secrets manager, separate for sandbox and production, and rotate it if it may have been exposed. Never log it.
5. Treating a valid signature as proof of payment
A valid signature means the provider sent the message. For anything that releases value, still confirm the transaction status and amount through the provider's API server-side. Our guide to payment webhooks explains why.
Protecting against replay attacks
If the provider includes a signed timestamp:
- Reject events whose timestamp is more than a few minutes old.
- Make sure your server clock is synchronised (NTP).
Regardless of timestamps, store each event ID with a unique constraint and ignore duplicates. This makes replays harmless and also handles legitimate retries. Our article on idempotency keys goes deeper.
Rotating signing secrets without downtime
A safe rotation process:
- Generate a new secret in the provider's dashboard (where supported).
- Deploy code that accepts signatures from either the old or new secret.
- Switch the provider to the new secret.
- After retries for old events have passed, remove the old secret.
Webhook signature verification checklist
- Raw request body captured as bytes
- HMAC computed exactly as the provider documents (algorithm, message, encoding)
- Constant-time comparison with a length check
- Invalid signatures rejected with a 4xx and logged
- Timestamps checked where provided; event IDs deduplicated
- Payment re-verified via API before fulfilment
- Secrets stored securely and rotated when needed
How CentraPoint helps
CentraPoint checks the signature on every inbound gateway webhook and then re-verifies the payment server-side before updating an invoice or subscription. It also sends outbound webhooks to your own systems and keeps webhook and audit logs; the webhooks documentation explains the event format and how to verify deliveries.
Frequently asked questions
What is webhook signature verification?
It's checking a cryptographic signature sent with a webhook, usually an HMAC of the request body, to confirm the request came from the expected provider and wasn't modified.
Why does my signature check fail even with the right secret?
The most common cause is verifying a parsed and re-serialised body instead of the raw bytes. Encoding mismatches (hex vs Base64) and extra whitespace in the stored secret are the next most likely.
Is HTTPS enough without signatures?
No. HTTPS protects data in transit but doesn't stop someone else from sending requests to your public endpoint. Signatures prove who sent the request.
Should I still call the provider's API after verifying the signature?
For events that release goods, access or money, yes. Confirming the status and amount server-side protects you against leaked secrets and your own bugs.
- #webhooks
- #security
- #hmac
- #developers