QA How-To
Test Webhook Signature Verification Step by Step
Learn to test webhook signature verification step by step with Node.js, Vitest, raw request bodies, timestamp checks, negative tests, and key rotation.
18 min read | 2,437 words
TL;DR
Sign the exact raw request body together with a timestamp, send the signature in a header, and assert that the receiver accepts only a matching HMAC inside the allowed time window. Add negative tests for tampering, missing or malformed headers, stale timestamps, replay attempts, and secret rotation.
Key Takeaways
- Compute the expected HMAC from the exact raw bytes received, not parsed or reserialized JSON.
- Compare signatures with a constant-time function after validating their encoding and length.
- Test valid, tampered, missing, malformed, stale, and future-dated requests as separate cases.
- Bind the timestamp and payload into the signed message to make captured requests harder to replay.
- Keep signing helpers independent from the verifier so tests do not repeat production logic.
- Test key rotation with both active secrets, then prove the retired secret is rejected.
To test webhook signature verification step by step, you need to control the raw payload, timestamp, secret, and signature independently. A strong test suite proves both sides of the boundary: authentic deliveries are accepted, while modified, stale, malformed, or unsigned deliveries are rejected before business logic runs.
This tutorial builds a small Node.js receiver and tests it with Vitest and Supertest. For the larger delivery lifecycle, read the Webhook API Testing Complete Guide for 2026. Here, the focus stays narrow: HMAC-SHA256 verification, timestamp tolerance, negative cases, replay protection, and secret rotation.
The examples use built-in Node.js cryptography and an Express raw-body route. The same testing pattern applies to other frameworks and providers, but always adopt the provider's documented header format and signed-message construction.
What You Will Build
By the end, you will have:
- A runnable webhook endpoint that verifies an HMAC-SHA256 signature over a timestamp and raw JSON bytes.
- A test-only signer that behaves like an external webhook producer.
- Positive and negative integration tests using real HTTP requests.
- Timestamp tolerance checks that reduce replay risk.
- A one-time event ID store and tests for duplicate delivery rejection.
- A safe key-rotation strategy with explicit regression coverage.
The receiver returns 204 only after authentication and JSON validation succeed. Authentication failures return 401, malformed authenticated JSON returns 400, and already processed event IDs return 409. These distinctions make failures observable without exposing secrets or expected signature values.
Prerequisites
Use Node.js 22 or a newer supported LTS release and npm. Confirm the tools first:
node --version
npm --version
Create an isolated project and install the packages:
mkdir webhook-signature-lab
cd webhook-signature-lab
npm init -y
npm install express
npm install --save-dev vitest supertest
npm pkg set type=module
npm pkg set scripts.test="vitest run"
You need no external API and should not put a real production secret in this lab. Tests use a deterministic dummy secret. In CI, inject test secrets through the runner's secret store and redact signature headers from logs.
| Test level | What it proves | Main limitation |
|---|---|---|
| Unit | Verifier behavior for controlled byte arrays | Does not prove middleware preserves raw bytes |
| HTTP integration | Header parsing, raw body capture, status codes, and verifier wiring | Does not prove the real provider's signing format |
| Provider sandbox | Compatibility with provider-generated deliveries | Can be slower and less deterministic |
| Production synthetic | Deployed routing, secrets, and observability | Must avoid unsafe data and side effects |
This tutorial emphasizes HTTP integration tests because raw-body handling is a common source of defects. Add provider sandbox tests when the provider offers a stable test-delivery feature.
Step 1: Define the Signature Contract
Choose an explicit contract before writing verification code. This lab uses these request headers:
x-webhook-timestamp: Unix time in seconds.x-webhook-signature: lowercase hexadecimal HMAC-SHA256.x-webhook-id: stable event identifier used for deduplication.
The signed message is the UTF-8 byte sequence timestamp + '.' + rawBody. Including the timestamp prevents an attacker from changing it without invalidating the signature. The dot provides an unambiguous boundary between the timestamp and body.
Create signature.js:
import { createHmac, timingSafeEqual } from 'node:crypto';
export function computeSignature({ secret, timestamp, rawBody }) {
return createHmac('sha256', secret)
.update(String(timestamp), 'utf8')
.update('.', 'utf8')
.update(rawBody)
.digest('hex');
}
export function verifySignature({ secret, timestamp, rawBody, supplied }) {
if (!/^[a-f0-9]{64}$/.test(supplied ?? '')) return false;
const expected = Buffer.from(
computeSignature({ secret, timestamp, rawBody }),
'hex'
);
const actual = Buffer.from(supplied, 'hex');
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
Do not compare secret-derived values with ===. timingSafeEqual reduces timing leakage, but it throws when buffers have different lengths, so validate the exact 64-character hexadecimal format first. This validation also prevents permissive decoding of malformed input.
Verify the step: run this one-line module check:
node -e "import('./signature.js').then(({computeSignature}) => console.log(computeSignature({secret:'test',timestamp:1,rawBody:Buffer.from('{}')}).length))"
Expected output is 64, the length of a SHA-256 digest encoded as hexadecimal.
Step 2: Preserve the Raw Request Body
Create app.js. Mount express.raw() on the webhook route before any JSON parser. A parsed object is not the signed input. Parsing and serializing can change whitespace, key order, escaping, or numeric representation even when the JSON has the same meaning.
import express from 'express';
import { verifySignature } from './signature.js';
export function createApp({ secrets, now = () => Date.now(), seen = new Set() }) {
const app = express();
app.post(
'/webhooks/orders',
express.raw({ type: 'application/json', limit: '256kb' }),
(req, res) => {
const timestamp = req.get('x-webhook-timestamp');
const supplied = req.get('x-webhook-signature');
const eventId = req.get('x-webhook-id');
if (!timestamp || !supplied || !eventId || !Buffer.isBuffer(req.body)) {
return res.status(401).json({ error: 'invalid webhook authentication' });
}
if (!/^\d{10}$/.test(timestamp)) {
return res.status(401).json({ error: 'invalid webhook authentication' });
}
const ageSeconds = Math.abs(Math.floor(now() / 1000) - Number(timestamp));
if (ageSeconds > 300) {
return res.status(401).json({ error: 'invalid webhook authentication' });
}
const authenticated = secrets.some((secret) =>
verifySignature({
secret,
timestamp,
rawBody: req.body,
supplied
})
);
if (!authenticated) {
return res.status(401).json({ error: 'invalid webhook authentication' });
}
let event;
try {
event = JSON.parse(req.body.toString('utf8'));
} catch {
return res.status(400).json({ error: 'invalid json' });
}
if (seen.has(eventId)) {
return res.status(409).json({ error: 'duplicate event' });
}
seen.add(eventId);
req.app.emit('verified-webhook', { eventId, event });
return res.sendStatus(204);
}
);
return app;
}
Authentication happens before parsing or emitting the business event. The response uses one generic message for authentication failures, so callers cannot distinguish an unknown secret from a malformed signature. In production, log a safe reason code internally, but never log the secret, raw signature, or full sensitive payload.
Verify the step: start the app through a temporary launcher or import it in Node. The key structural check is that req.body is a Buffer on this route. If a global express.json() middleware runs first, move this raw route above it or use the framework's supported raw-body capture hook.
Step 3: Send a Correctly Signed Request
Create app.test.js with a signer owned by the test. It uses the documented contract, not the production computeSignature function. Keeping the oracle separate reduces the chance that the same defect appears in both implementation and expected result.
import { createHmac } from 'node:crypto';
import request from 'supertest';
import { describe, expect, it, vi } from 'vitest';
import { createApp } from './app.js';
const SECRET = 'integration-test-secret-not-for-production';
const NOW_MS = Date.UTC(2026, 6, 15, 12, 0, 0);
const timestamp = String(Math.floor(NOW_MS / 1000));
function testSign({ secret = SECRET, at = timestamp, body }) {
return createHmac('sha256', secret)
.update(Buffer.concat([Buffer.from(`${at}.`), body]))
.digest('hex');
}
function signedPost(app, {
body,
at = timestamp,
secret = SECRET,
eventId = 'evt_001',
signature = testSign({ secret, at, body })
}) {
return request(app)
.post('/webhooks/orders')
.set('content-type', 'application/json')
.set('x-webhook-timestamp', at)
.set('x-webhook-id', eventId)
.set('x-webhook-signature', signature)
.send(body);
}
describe('webhook signature verification', () => {
it('accepts an authentic request and emits the parsed event', async () => {
const app = createApp({ secrets: [SECRET], now: () => NOW_MS });
const listener = vi.fn();
app.on('verified-webhook', listener);
const body = Buffer.from('{"type":"order.created","data":{"id":42}}');
await signedPost(app, { body }).expect(204);
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith({
eventId: 'evt_001',
event: { type: 'order.created', data: { id: 42 } }
});
});
});
The assertion checks more than the status. It proves the endpoint authenticated the exact bytes, parsed them only afterward, and handed the expected event to business processing once.
Verify the step: run:
npm test
Vitest should report one passing test. If you receive 401, inspect whether Supertest sent the exact Buffer and whether another body parser consumed it first.
Step 4: Test Webhook Signature Verification Step by Step for Tampering
A positive test cannot prove that verification is effective. Add focused negative tests inside the same describe block. Every rejected request must leave the business listener untouched.
it('rejects a body changed after signing', async () => {
const app = createApp({ secrets: [SECRET], now: () => NOW_MS });
const listener = vi.fn();
app.on('verified-webhook', listener);
const original = Buffer.from('{"amount":100}');
const tampered = Buffer.from('{"amount":900}');
const signature = testSign({ body: original });
await signedPost(app, { body: tampered, signature }).expect(401);
expect(listener).not.toHaveBeenCalled();
});
it('rejects a signature made with another secret', async () => {
const app = createApp({ secrets: [SECRET], now: () => NOW_MS });
const body = Buffer.from('{"type":"order.created"}');
await signedPost(app, { body, secret: 'wrong-secret' }).expect(401);
});
it.each([
['missing', undefined],
['too short', 'ab12'],
['non-hex', 'z'.repeat(64)],
['uppercase', 'A'.repeat(64)]
])('rejects a %s signature', async (_label, badSignature) => {
const app = createApp({ secrets: [SECRET], now: () => NOW_MS });
const body = Buffer.from('{}');
let call = request(app)
.post('/webhooks/orders')
.set('content-type', 'application/json')
.set('x-webhook-timestamp', timestamp)
.set('x-webhook-id', 'evt_bad');
if (badSignature !== undefined) {
call = call.set('x-webhook-signature', badSignature);
}
await call.send(body).expect(401);
});
Also test whitespace sensitivity. Sign '{"ok":true}' and send '{ "ok": true }'. Both are valid and semantically equal JSON, but their signatures must differ because webhook authentication covers bytes, not parsed meaning.
Verify the step: run npm test. All parameterized cases should pass without uncaught errors. An uncaught buffer-length error means format validation occurs too late. A 204 for the modified body means the receiver is hashing the wrong value or bypassing authentication.
Step 5: Test Timestamp Tolerance and Replay Windows
A valid HMAC can be copied and replayed. Require a signed timestamp near the receiver's clock and test both boundaries. This lab accepts an absolute age of 300 seconds, including exactly 300, and rejects 301.
it.each([
['current', 0, 204],
['exactly five minutes old', -300, 204],
['too old', -301, 401],
['exactly five minutes ahead', 300, 204],
['too far in the future', 301, 401]
])('%s timestamp returns %i', async (_label, offset, expected) => {
const app = createApp({ secrets: [SECRET], now: () => NOW_MS });
const at = String(Number(timestamp) + offset);
const body = Buffer.from('{"type":"clock.test"}');
await signedPost(app, {
body,
at,
eventId: `evt_${offset}`
}).expect(expected);
});
it.each(['', 'not-a-time', '1720000000.5', '-1720000000', '123'])('
rejects malformed timestamp %j', async (at) => {
const app = createApp({ secrets: [SECRET], now: () => NOW_MS });
const body = Buffer.from('{}');
const signature = testSign({ at, body });
await request(app)
.post('/webhooks/orders')
.set('content-type', 'application/json')
.set('x-webhook-timestamp', at || ' ')
.set('x-webhook-id', 'evt_time')
.set('x-webhook-signature', signature)
.send(body)
.expect(401);
});
Freezing the clock makes boundary tests deterministic. Do not depend on the test machine's current time. In a distributed production system, monitor clock synchronization and choose tolerance according to the provider's documented retry behavior and your threat model. A wide window helps delayed deliveries but gives captured requests longer to remain usable.
Verify the step: run the suite repeatedly. The result must not flicker around a real-time boundary. If a future timestamp passes unexpectedly, check that you used absolute age or explicit lower and upper bounds.
Step 6: Reject Duplicate Event IDs
Timestamp tolerance limits replay duration but does not make a request one-time. Store processed event IDs for at least the useful replay period, preferably according to the provider's retry horizon. The in-memory Set is suitable only for this lab. Production needs a shared durable store with an atomic insert or uniqueness constraint.
it('processes an event ID once even when the signed request is replayed', async () => {
const seen = new Set();
const app = createApp({ secrets: [SECRET], now: () => NOW_MS, seen });
const listener = vi.fn();
app.on('verified-webhook', listener);
const body = Buffer.from('{"type":"payment.completed"}');
await signedPost(app, { body, eventId: 'evt_replay' }).expect(204);
await signedPost(app, { body, eventId: 'evt_replay' }).expect(409);
expect(listener).toHaveBeenCalledOnce();
expect(seen.has('evt_replay')).toBe(true);
});
There is an important production ordering concern. Marking an ID before business processing can lose work if processing fails. Marking it afterward can allow concurrent duplicates. A robust design atomically records an inbox item, acknowledges the delivery, and lets an idempotent worker process it. Study how to validate webhook event ordering and duplicates for the deeper state and concurrency cases.
Verify the step: confirm the first identical request returns 204, the second returns 409, and the listener count remains one. If both process, your event ID store is not shared or the check-and-insert operation is not atomic.
Step 7: Test Secret Rotation Without Downtime
Receivers often need to accept an old and a new secret during a controlled overlap. The app already accepts a secrets array and succeeds when any active secret verifies. Add tests for the overlap and retirement phases.
it('accepts old and new secrets during rotation', async () => {
const oldSecret = 'old-test-secret';
const newSecret = 'new-test-secret';
const app = createApp({
secrets: [newSecret, oldSecret],
now: () => NOW_MS
});
const first = Buffer.from('{"rotation":"old"}');
const second = Buffer.from('{"rotation":"new"}');
await signedPost(app, {
body: first, secret: oldSecret, eventId: 'evt_old'
}).expect(204);
await signedPost(app, {
body: second, secret: newSecret, eventId: 'evt_new'
}).expect(204);
});
it('rejects the retired secret after rotation', async () => {
const app = createApp({ secrets: ['new-test-secret'], now: () => NOW_MS });
const body = Buffer.from('{"rotation":"retired"}');
await signedPost(app, {
body, secret: 'old-test-secret', eventId: 'evt_retired'
}).expect(401);
});
Keep the overlap short and intentional. Load secrets from a secret manager, not source control. If the provider includes a key ID, select the matching active key instead of trying every key. Never accept an unlimited history of secrets, because each retained key remains an authentication path.
Verify the step: both overlap requests should pass, then the old-secret request against the post-rotation app should fail. This sequence proves the deployment can rotate without a blind acceptance gap.
Step 8: Add the Suite to CI and Provider Testing
Run the deterministic integration suite on every change. A minimal GitHub Actions workflow is enough:
name: webhook-signature-tests
on:
pull_request:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
Keep the core suite offline. Add a separate scheduled or manually triggered provider-sandbox test that asks the provider to send a test event to a temporary endpoint. That test detects header or canonicalization changes outside your code, but it should not make pull requests flaky.
For diagnosing captured deliveries safely, save a sanitized payload fixture and explicitly supplied test headers. The guide to replaying webhook payloads for local testing shows how to reproduce receiver behavior without waiting for another external event. Never copy a production secret into a developer command or commit a still-valid signature fixture.
Verify the step: open the CI run and confirm the job installs from the lockfile and reports the same passing count as local execution. Then deliberately alter one payload after signing in a temporary branch. CI must fail the affected assertion. Revert that deliberate mutation before merging.
Troubleshooting
Every valid request returns 401 -> Capture the raw bytes before JSON parsing. Confirm the provider's exact signed-message formula, digest encoding, header prefix, timestamp unit, and character encoding. A hexadecimal digest is not interchangeable with Base64.
Local tests pass but provider deliveries fail -> Your test signer may reproduce your implementation instead of the provider contract. Compare against an official provider fixture or sandbox delivery. Check whether the header contains multiple versioned signatures such as v1=....
Signature changes after logging or proxying -> Do not reconstruct the request from a logged object. Verify bytes at the first trusted application boundary, and check whether a gateway decompresses or transforms the body before the application sees it.
timingSafeEqual throws -> Validate encoding and digest length before converting and comparing buffers. Treat malformed values as authentication failures, not server errors.
Fresh requests intermittently look stale -> Freeze time in tests and synchronize production hosts. Confirm whether timestamps use seconds or milliseconds. Log safe age and reason-code fields, not signature material.
Legitimate retries return 409 -> Decide whether your public response should acknowledge known duplicates with 200 or 204 to stop further retries. The lab uses 409 to make the assertion visible, but many production receivers acknowledge duplicates successfully after confirming the original event was safely recorded. For delivery timing coverage, test webhook retry and backoff behavior.
Best Practices and Common Mistakes
Do verify the exact raw byte sequence and authenticate before JSON parsing, schema validation, database writes, or queue publication. Put a strict size limit on the raw-body middleware to prevent oversized requests from consuming excessive memory.
Do define the signature contract as data: algorithm, signed fields, separator, timestamp unit, encoding, header grammar, and active key selection. Turn each rule into at least one positive and one negative test.
Do use constant-time comparison for equal-length decoded digests. Constant-time comparison does not replace header validation, secret protection, TLS, replay controls, or authorization of event types.
Do test observability. Internal logs should contain an event ID, provider name, safe failure reason, request age, and correlation ID. They should not contain secrets, full authentication headers, or sensitive payloads.
Do not create signatures from parsed objects in integration tests. Do not use the production signer as the only test oracle. Do not accept stale requests simply because their HMAC is correct. Do not make side effects before authentication completes.
Finally, treat duplicate delivery as normal webhook behavior. Signature verification establishes authenticity and integrity, not exactly-once processing or correct business semantics.
Where To Go Next
Place this suite inside a broader webhook test strategy. The complete webhook API testing guide connects security, delivery, observability, contract, and failure testing.
Next, add these focused workflows:
- Test webhook retry and exponential backoff behavior to prove failures are retried at acceptable intervals and eventually stop.
- Validate webhook event ordering and duplicate handling to cover concurrency, idempotency, and out-of-order state changes.
- Replay webhook payloads in local testing to reproduce failures with sanitized fixtures and controlled headers.
Also add schema tests after authentication and authorization tests for event types or tenant context. A valid provider signature proves who signed the bytes. It does not prove the payload is structurally valid, relevant to the receiving tenant, or safe to process twice.
Interview Questions and Answers
Q: Why must webhook verification use the raw request body?
An HMAC authenticates bytes. JSON parsing and serialization can change whitespace, ordering, escaping, or number formatting, producing different bytes and therefore a different digest. Preserve the exact body received until verification completes.
Q: Why include a timestamp in the signed message?
A timestamp lets the receiver reject captured requests outside a short tolerance. Signing it prevents an attacker from replacing an old timestamp with a current one. A timestamp window should be combined with event ID deduplication for stronger replay protection.
Q: What negative cases belong in webhook signature tests?
Test missing, malformed, incorrectly encoded, wrong-secret, body-tampered, timestamp-tampered, stale, and future-dated requests. Assert both the response and the absence of business side effects. Include boundary values for the allowed clock tolerance.
Q: Is constant-time comparison enough to secure a webhook?
No. It only reduces timing leakage during digest comparison. You still need TLS, secret management, raw-body verification, strict parsing, timestamp tolerance, replay controls, input limits, and safe observability.
Q: How do you test webhook secret rotation?
During overlap, prove deliveries signed by old and new secrets both pass. After retirement, prove the old secret fails and the new one still passes. Keep the number of active secrets bounded and use a key ID when the provider supports one.
Q: Should a duplicate webhook return an error?
Often it should return a success response after the receiver confirms the original event is safely recorded, because duplicates are expected during retries. A test may use 409 to expose deduplication behavior, but production response semantics should match the provider contract and retry policy.
Conclusion
To test webhook signature verification step by step, sign a known raw payload and timestamp, send it through the real HTTP middleware path, and assert that only the authentic request reaches business processing. Then mutate one input at a time: payload, timestamp, signature, secret, encoding, and event ID.
Keep the suite deterministic with a frozen clock and isolated state. Add durable deduplication, controlled key rotation, safe logs, and provider sandbox coverage before treating the receiver as production-ready.
Interview Questions and Answers
Why do you verify a webhook signature before parsing JSON?
The signature authenticates the exact request bytes, and JSON parsing can alter their representation. I capture the raw body with a strict size limit, verify the signature and timestamp, and only then parse and validate the event. This also prevents unauthenticated input from reaching business logic.
How would you design negative tests for HMAC webhook verification?
I change one factor at a time: body bytes, secret, timestamp, digest encoding, signature length, or header presence. I cover stale and future timestamp boundaries and malformed inputs. For every rejection, I assert the status and prove no queue message, database write, or handler call occurred.
What does timing-safe comparison protect against?
It reduces information leakage caused by comparisons that stop at the first different byte. I first validate and decode the signature so both buffers have the expected equal length, then use a constant-time comparison. It is one control and does not replace TLS, replay prevention, or secure secret storage.
How do timestamp and event ID checks work together?
The signed timestamp limits how long a captured request remains acceptable. The event ID prevents the same valid event from being processed more than once inside that window or during provider retries. In production, I use an atomic durable insert or inbox pattern rather than an in-memory set.
How would you rotate a webhook signing secret safely?
I deploy support for a bounded set containing the new and old secrets, switch the producer, observe successful new-key traffic, and then remove the old secret. Tests prove both keys work during overlap and the retired key fails afterward. If a key ID is available, I use it to choose the intended active key.
Why should a test signer be separate from production signing code?
Reusing production logic as the oracle can reproduce the same bug in the expected value and implementation. I write the test signer directly from the external contract or use official fixtures. I also include at least one known digest vector or provider sandbox delivery for independent compatibility evidence.
Frequently Asked Questions
How do I test a webhook signature locally?
Use a known test secret to compute the provider's documented HMAC over the exact raw payload and any signed timestamp. Send the raw bytes and headers to your local endpoint, then assert the response and downstream side effects. Add tampered and stale variants to prove rejection works.
Should webhook signatures be generated from parsed JSON?
No. Generate and verify the signature from the exact raw bytes because parsing and reserialization can change whitespace, key order, escaping, or numeric formatting. Parse JSON only after signature verification succeeds.
What status code should an invalid webhook signature return?
A generic `401 Unauthorized` is a common choice for missing or invalid webhook authentication. Use the same public message across authentication failures and keep detailed, non-sensitive reason codes in internal logs. Follow the provider contract if it requires specific response behavior.
How can I test webhook replay attacks?
Send a correctly signed request with a timestamp just outside the allowed tolerance and expect rejection. Then send the same fresh signed event ID twice and prove business processing occurs once. Use an atomic, durable event ID store in production.
Why does my webhook signature match in a script but fail in the app?
The application may be hashing a parsed or transformed body rather than the bytes the provider signed. Check middleware order, decompression, encoding, timestamp units, signature encoding, header prefixes, and the provider's exact signed-message format.
How long should webhook timestamp tolerance be?
Use the provider's guidance and the smallest window compatible with normal network delay and delivery behavior. Five minutes is a common illustrative value, not a universal rule. Monitor clock synchronization and combine the window with event ID deduplication.
Related Guides
- Azure DevOps test pipelines: Step by Step (2026)
- CircleCI for test automation: Step by Step (2026)
- Flaky test quarantine in CI: Step by Step (2026)
- GitLab CI for test automation: Step by Step (2026)
- Grafana dashboards for test metrics: Step by Step (2026)
- Parallel test sharding in CI: Step by Step (2026)