Securing Webhooks with HMAC Signature Verification
When your API calls a subscriber’s endpoint, that endpoint is a public URL — anyone who learns it can POST forged events to it. HMAC signature verification fixes this: the sender signs each delivery with a shared secret, and the receiver recomputes the signature to prove the request is authentic and unmodified. This guide shows how to document the signature header in OpenAPI 3.1, define the signing scheme, and verify deliveries in Node.js with a timing-safe compare. It is part of the Webhook & Callback Definitions section and the broader OpenAPI & AsyncAPI Schema Authoring framework. By the end you will have a self-contained spec fragment and a receiver that rejects forged and replayed requests.
Problem & Context
A webhook receiver accepts unauthenticated POST requests from the public internet. Without verification, an attacker who discovers the URL can replay old events, inject fabricated ones (a fake payment.succeeded, for example), or tamper with the payload in transit. TLS protects the request on the wire but proves nothing about who sent it.
HMAC signature verification solves authenticity and integrity with a single shared secret. The sender computes HMAC-SHA256(secret, raw_body) and transmits the result in a header such as X-Signature-256. The receiver, holding the same secret, recomputes the HMAC over the bytes it received and compares. A match proves two things at once: the request came from someone holding the secret, and the body was not altered. Stripe, GitHub, and Shopify all use this exact pattern, which is why subscribers expect it.
The spec is where this contract is documented so subscribers know which header to read and how to recompute the digest. OpenAPI 3.1’s native webhooks object can declare the header, but HMAC verification is not expressible as a securityScheme — there is no securityScheme.type for “HMAC over the body”. So the scheme lives in a required header parameter plus precise prose, which complements the metadata approach covered in documenting webhook callbacks with OpenAPI extensions.
Step-by-Step Solution
1. Document the signature header in the OpenAPI 3.1 spec
Declare the signature and timestamp headers as required parameters on the webhook operation. The timestamp is what lets the receiver reject replays.
# openapi.yaml (OpenAPI 3.1.0)
openapi: 3.1.0
info:
title: Payments API
version: 1.0.0
webhooks:
payment.succeeded:
post:
operationId: onPaymentSucceeded
summary: Fired when a payment is captured
description: >
Each delivery is signed. Compute HMAC-SHA256 over the raw request
body using your endpoint signing secret and compare it to the
X-Signature-256 header (prefixed with "sha256=", lowercase hex).
Reject requests whose X-Timestamp is more than 300 seconds old.
parameters:
- $ref: '#/components/parameters/SignatureHeader'
- $ref: '#/components/parameters/TimestampHeader'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PaymentEvent'
responses:
'200':
description: Event accepted
'401':
description: Signature invalid or timestamp stale
2. Define the signing scheme as reusable components
Centralize the header definitions so every webhook operation references the same contract.
components:
parameters:
SignatureHeader:
name: X-Signature-256
in: header
required: true
description: 'HMAC-SHA256 of the raw body, formatted as "sha256=<hex>".'
schema:
type: string
pattern: '^sha256=[0-9a-f]{64}$'
TimestampHeader:
name: X-Timestamp
in: header
required: true
description: Unix epoch seconds when the event was signed.
schema:
type: integer
format: int64
schemas:
PaymentEvent:
type: object
required: [id, type, created, data]
properties:
id: { type: string }
type: { type: string, const: payment.succeeded }
created: { type: integer, format: int64 }
data:
type: object
properties:
amount: { type: integer }
currency: { type: string }
The pattern on the signature header documents the exact wire format and lets Spectral reject malformed examples in CI. Validate the spec parses cleanly:
npx @redocly/cli@2 lint openapi.yaml
Expected output:
validating openapi.yaml...
openapi.yaml: valid
Woohoo! Your API description is valid. 🎉
3. Capture the raw request body on the receiver
The HMAC must be computed over the exact bytes the sender signed. If a body parser re-serializes the JSON first, key order and whitespace can change and the digest will never match. In Express, capture the raw buffer with express.raw:
const express = require('express');
const app = express();
// Capture the raw body ONLY for the webhook route.
app.post('/webhooks/payments',
express.raw({ type: 'application/json' }),
handleWebhook
);
req.body is now a Buffer of the unparsed bytes — exactly what you sign against.
4. Compute the expected signature
Use Node’s built-in crypto module. The secret is the per-endpoint signing secret you issued to the subscriber, read from the environment, never hardcoded.
const crypto = require('crypto');
function expectedSignature(rawBody, secret) {
const digest = crypto
.createHmac('sha256', secret)
.update(rawBody) // rawBody is a Buffer
.digest('hex');
return `sha256=${digest}`;
}
5. Compare with crypto.timingSafeEqual and reject replays
A === comparison short-circuits on the first differing byte, leaking timing information an attacker can exploit to forge a valid signature. Use crypto.timingSafeEqual, which compares the full buffer in constant time. It throws if the two buffers differ in length, so guard that first.
function signaturesMatch(received, expected) {
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
if (a.length !== b.length) return false; // length mismatch is safe to leak
return crypto.timingSafeEqual(a, b);
}
Combine the steps and add the timestamp freshness check. Run the receiver and POST a forged request:
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST http://localhost:3000/webhooks/payments \
-H 'Content-Type: application/json' \
-H 'X-Signature-256: sha256=deadbeef' \
-H "X-Timestamp: $(date +%s)" \
-d '{"id":"evt_1","type":"payment.succeeded"}'
Expected output (forged signature rejected):
401
A request signed with the correct secret returns 200.
Complete Working Example
A single self-contained server.js. Set WEBHOOK_SECRET in the environment before running.
// server.js — run: WEBHOOK_SECRET=whsec_test node server.js
// Node 18+; no third-party deps except express.
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.WEBHOOK_SECRET;
const MAX_SKEW_SECONDS = 300; // reject deliveries older than 5 minutes
if (!SECRET) {
throw new Error('WEBHOOK_SECRET is required');
}
function expectedSignature(rawBody, secret) {
const digest = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
return `sha256=${digest}`;
}
function signaturesMatch(received, expected) {
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
function isFresh(timestampHeader) {
const ts = Number.parseInt(timestampHeader, 10);
if (!Number.isFinite(ts)) return false;
const skew = Math.abs(Math.floor(Date.now() / 1000) - ts);
return skew <= MAX_SKEW_SECONDS;
}
app.post(
'/webhooks/payments',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.get('X-Signature-256') || '';
const timestamp = req.get('X-Timestamp') || '';
if (!isFresh(timestamp)) {
return res.status(401).json({ error: 'stale_or_missing_timestamp' });
}
const expected = expectedSignature(req.body, SECRET);
if (!signaturesMatch(signature, expected)) {
return res.status(401).json({ error: 'invalid_signature' });
}
// Safe to parse only AFTER verification succeeds.
const event = JSON.parse(req.body.toString('utf8'));
console.log('verified event', event.id, event.type);
return res.status(200).json({ received: true });
}
);
app.listen(3000, () => console.log('listening on :3000'));
To generate a valid signed request for testing:
BODY='{"id":"evt_1","type":"payment.succeeded","created":1,"data":{"amount":500,"currency":"usd"}}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "whsec_test" | awk '{print $2}')"
curl -s -X POST http://localhost:3000/webhooks/payments \
-H 'Content-Type: application/json' \
-H "X-Signature-256: $SIG" \
-H "X-Timestamp: $(date +%s)" \
-d "$BODY"
Expected output:
{"received":true}
Gotchas & Edge Cases
Signing the parsed body instead of the raw bytes. The single most common failure. express.json() parses and discards the original bytes, so re-stringifying produces different whitespace and key order. Always sign the raw Buffer, and register the JSON parser only on non-webhook routes — never globally.
No timestamp means no replay protection. Signatures alone do not stop replays: a captured valid request is valid forever. Require an X-Timestamp header, fold it into the freshness check, and for stricter senders sign the concatenation of timestamp and body (HMAC(secret, timestamp + "." + body)) so the timestamp itself is tamper-proof.
Secret rotation breaks in-flight deliveries. When you rotate a signing secret, accept both the old and new secret for an overlap window. Compute the expected signature against each active secret and accept if either matches with timingSafeEqual; retire the old secret only after the retry window has elapsed.
FAQ
Why must I use a timing-safe compare instead of ===?
A normal string comparison returns as soon as it finds the first differing byte, so the time it takes leaks how many leading bytes of the signature were correct. crypto.timingSafeEqual always compares the full buffer in constant time, which removes that side channel and prevents an attacker from guessing the signature byte by byte.
Should I sign the raw request body or the parsed JSON?
Always sign and verify the exact raw bytes of the body before any JSON parsing or re-serialization. Re-serializing JSON can reorder keys or change whitespace, which produces a different HMAC and breaks verification even when the payload is unchanged.
How do I document the signature scheme in OpenAPI 3.1?
Declare the signature header as a required parameter on the webhook operation and describe the signing algorithm, secret source, and signed input in the operation description or an x-signature extension. OpenAPI cannot express HMAC verification as a security scheme, so the scheme lives in the header definition plus prose that your portal renders.
Related
- Webhook & Callback Definitions — parent section on documenting webhooks and callbacks
- Documenting webhook callbacks with OpenAPI extensions — express signature and retry metadata with
x-extensions - Retry and idempotency for webhooks — make verified deliveries safe to receive more than once
- OpenAPI & AsyncAPI Schema Authoring — the broader schema authoring framework
- Spec linting & governance — enforce header contracts with Spectral in CI