How to check webhook signature
Recompute the HMAC over the raw request body and compare it with the header the sender attached: printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'whsec_local_test' -r. A receiver that hashes the same bytes answers 200. The same request with one digit of the amount changed answers 401, and the header never moved.
Why check this
Run this the first time a service accepts callbacks, after the signing secret is rotated, and after any change to the body parser or to the proxy in front of the endpoint. The failure it prevents is an endpoint that logs a mismatch and answers 200 anyway. Anyone who learns the URL can then post a payment_succeeded event for an order nobody paid for, and the order ships. The receiver prints both digests on every request, so a failure also tells you which side computed the wrong one.
Prerequisites
- Node 22 and the receiver below, saved as
verify.js. It hashes the bytes that arrived and never a parsed object. - openssl 3, or any tool that hashes bytes. See the openssl dgst manual.
- curl, for the two deliveries.
- The shared secret. This page uses
whsec_local_test, read fromWEBHOOK_SECRET. A production secret belongs in neither a shell history nor a browser tab. - Port 8921 free. Confirm with
netstat -ano | grep 8921before starting, and stop the receiver when the check is over.
// verify.js - a webhook receiver that signs the raw body and compares in constant time.
const http = require('node:http');
const crypto = require('node:crypto');
const SECRET = process.env.WEBHOOK_SECRET || 'whsec_local_test';
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks);
const sent = String(req.headers['x-signature'] || '');
const want = crypto.createHmac('sha256', SECRET).update(raw).digest('hex');
const a = Buffer.from(sent, 'utf8');
const b = Buffer.from(want, 'utf8');
const ok = a.length === b.length && crypto.timingSafeEqual(a, b);
console.log(`bytes=${raw.length} sent=${sent || '(none)'} want=${want} ${ok ? 'OK' : 'MISMATCH'}`);
res.writeHead(ok ? 200 : 401, { 'content-type': 'text/plain' }).end(ok ? 'accepted\n' : 'bad signature\n');
});
}).listen(8921, '127.0.0.1', () => console.log('receiver on http://127.0.0.1:8921/hook'));
Step 7 uses a second copy of the same receiver with the length test removed, saved as noguard.js on port 8926.
// noguard.js - the same receiver with the length check left out.
const http = require('node:http');
const crypto = require('node:crypto');
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks);
const want = crypto.createHmac('sha256', 'whsec_local_test').update(raw).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(String(req.headers['x-signature'] || '')), Buffer.from(want));
res.writeHead(ok ? 200 : 401).end();
});
}).listen(8926, '127.0.0.1', () => console.log('no-guard receiver on http://127.0.0.1:8926/hook'));
Steps
- Step 1.
Start the receiver in its own terminal and keep its log on disk.
node verify.js | tee verify.logreceiver on http://127.0.0.1:8921/hook - Step 2.
Sign the exact bytes you are about to send.
BODY='{"id":"evt_1","type":"payment_succeeded","amount":2599}' SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'whsec_local_test' -r | cut -d' ' -f1) echo "$SIG"038ea7be4bd3fc7e10e740bfd97f1937d965ce0008ac2b284beae8995808fe13printf '%s'is doing real work in that line.echoappends a newline, and that newline is a byte the HMAC covers. - Step 3.
Deliver the body with that signature.
curl -s -w '%{http_code}\n' -X POST 'http://127.0.0.1:8921/hook' -H 'content-type: application/json' -H "x-signature: $SIG" --data-raw "$BODY"accepted 200 - Step 4.
Change the amount from 2599 to 2699 and send the untouched header again.
curl -s -w '%{http_code}\n' -X POST 'http://127.0.0.1:8921/hook' -H 'content-type: application/json' -H "x-signature: $SIG" --data-raw '{"id":"evt_1","type":"payment_succeeded","amount":2699}'bad signature 401An endpoint that answers 200 here accepts any payload from anyone who knows the URL, and its signature header is decoration.
- Step 5.
Read the receiver log. Both digests are printed on every request, which is what turns a failed check into a diagnosis.
cat verify.logreceiver on http://127.0.0.1:8921/hook bytes=55 sent=038ea7be4bd3fc7e10e740bfd97f1937d965ce0008ac2b284beae8995808fe13 want=038ea7be4bd3fc7e10e740bfd97f1937d965ce0008ac2b284beae8995808fe13 OK bytes=55 sent=038ea7be4bd3fc7e10e740bfd97f1937d965ce0008ac2b284beae8995808fe13 want=9690f7a33d30a2494bdc0f976884184dc71cc2997f4c9a7c71fa104e15452200 MISMATCHBoth lines read 55 bytes, so the tampered body is exactly as long as the original and the digest still changes completely. Length tells you nothing here. The digest tells you everything.
- Step 6.
Print the same digest in both encodings, and print what a trailing newline does to it.
node -e "const{createHmac}=require('node:crypto');const b='{\"id\":\"evt_1\",\"type\":\"payment_succeeded\",\"amount\":2599}';const m=s=>createHmac('sha256','whsec_local_test').update(s).digest();console.log('hex '+m(b).toString('hex'));console.log('base64 '+m(b).toString('base64'));console.log('hex, trailing newline '+m(b+'\n').toString('hex'))"hex 038ea7be4bd3fc7e10e740bfd97f1937d965ce0008ac2b284beae8995808fe13 base64 A46nvkvT/H4Q50C/2X8ZN9llzgAIrCsoS+romVgI/hM= hex, trailing newline dae719a5ab1fe17781e8dfc073d611a4025fcfab6a766a2b7f51c039da95ef71The first two lines are the same 32 digest bytes written two ways, 64 characters against 44. The third line is a different body, one newline longer, and its digest shares nothing with the first.
- Step 7.
Start
noguard.jsfrom the Prerequisites block, then post it a header in another scheme's format.curl -s -o /dev/null -w 'curl exit %{exitcode}, http status %{http_code}\n' -X POST 'http://127.0.0.1:8926/hook' -H 'x-signature: sha256=038ea7be4bd3fc7e10e740bfd97f1937d965ce0008ac2b284beae8995808fe13' --data-raw '{"id":"evt_1"}'curl exit 56, http status 000There is no status because there is no longer a process.
crypto.timingSafeEqualthrows on unequal lengths, the throw escapes the request handler, and the receiver terminal holds the reason:RangeError: Input buffers must have the same byte length at IncomingMessage.<anonymous> (…noguard.js:10:23) … code: 'ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH' }Seven characters of prefix in a header that anyone can set stopped the service. The length test on the line above the comparison in
verify.jsis what prevents it.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| 200 and OK in the log | The body and the secret both match | Record the byte count. It is the number to compare against when a later delivery fails. |
| 401, digests of equal length | The algorithm agrees, the inputs do not | Compare the byte counts first. A different count is a body problem, an equal count points at the secret or the encoding. |
| 401, sent is 44 characters | The sender encoded in base64, the receiver in hex | Decode both to bytes before comparing, or agree on one encoding. |
| 401, sent is 40 characters | That is SHA-1, not SHA-256 | Read the header name the sender documents. Some senders still attach both. |
| curl exit 56, no status | The receiver process died during the request | Guard the length before the constant-time comparison, as step 7 shows. |
| 200 for every body you send | Nothing is being verified | Find the code path that skips the check. A mismatch that logs a warning and continues is the usual shape. |
Common mistakes
What to check next
- Webhook signature verification failed: what to do when the digests differ and the secret is provably right.
- Github webhook signature verification: the same scheme with a published test vector to check an implementation against.
- Stripe webhook signature verification: a scheme where the signed string is not the body alone.
- How to test webhook delivery: the transport, the retries and the delivery id around this check.
- How to verify JWT signature: the same raw-bytes rule applied to a token.
- Payment gateway testing checklist: where signature checks sit in a release pass.
FAQ
How do I verify an HMAC-SHA-256 signature?
Hash the exact bytes the sender hashed, with the shared secret as the key, then compare the result with the header. The comparison is on bytes, so decode hex or base64 first. Step 2 is the sender half and verify.js is the receiver half.
Is the signature hex or base64?
Both encode the same 32 bytes. Hex is 64 characters, base64 is 44 and ends in an equals sign. Step 6 prints one digest in both forms. Read the sender's documentation, and treat a length that is neither as a different hash function.
Why does the signature match in my test and fail in staging?
The fixture is usually compact JSON that survives a parse and a re-serialisation unchanged, so a receiver that hashes a re-serialised body passes on it. A real sender formats differently. The raw-body page measures that difference in bytes.
Does a valid signature mean the request is safe to process?
It proves the body was not altered and that the sender holds the secret. It proves nothing about freshness. A captured request replays unchanged until the receiver also checks a timestamp and remembers event ids.
Can I run this against a public endpoint?
Run it against a service you own. Sending crafted payloads at somebody else's receiver is a different activity with a different name. Every command on this page targets 127.0.0.1.
Verified
Verified by Maks Vernycurl 8.21.0openssl 3.1.1node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
- Checker: webhook-signature compute and compare an HMAC-SHA256 signature for a payload and secret (Stripe, GitHub, generic formats) with WebCrypto
- Payment gateway testing checklist
- All payments and webhooks checks
intermediate8 minpublished updated Maks Verny