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

// 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

  1. Step 1.

    Start the receiver in its own terminal and keep its log on disk.

    node verify.js | tee verify.log
    
    receiver on http://127.0.0.1:8921/hook
  2. 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"
    
    038ea7be4bd3fc7e10e740bfd97f1937d965ce0008ac2b284beae8995808fe13

    printf '%s' is doing real work in that line. echo appends a newline, and that newline is a byte the HMAC covers.

  3. 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
  4. 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
    401

    An endpoint that answers 200 here accepts any payload from anyone who knows the URL, and its signature header is decoration.

  5. 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.log
    
    receiver on http://127.0.0.1:8921/hook
    bytes=55 sent=038ea7be4bd3fc7e10e740bfd97f1937d965ce0008ac2b284beae8995808fe13 want=038ea7be4bd3fc7e10e740bfd97f1937d965ce0008ac2b284beae8995808fe13 OK
    bytes=55 sent=038ea7be4bd3fc7e10e740bfd97f1937d965ce0008ac2b284beae8995808fe13 want=9690f7a33d30a2494bdc0f976884184dc71cc2997f4c9a7c71fa104e15452200 MISMATCH

    Both 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.

  6. 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   dae719a5ab1fe17781e8dfc073d611a4025fcfab6a766a2b7f51c039da95ef71

    The 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.

  7. Step 7.

    Start noguard.js from 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 000

    There is no status because there is no longer a process. crypto.timingSafeEqual throws 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.js is 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

Sign: The signature agrees with an online tool and fails against your own receiver.Cause: The shell signed a different body. echo appends a newline, so 56 bytes were hashed while curl sent 55. Step 6 prints both digests for the same event: 038ea7be for the body, dae719a5 for the body plus one newline. Use printf '%s', or read the body from a file.
Sign: One malformed header takes the endpoint down and the load balancer marks the instance unhealthy.Cause: crypto.timingSafeEqual throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH when the two buffers differ in length, and the length of the incoming header is chosen by whoever sends the request. Compare lengths first and answer 401, which is what step 7 demonstrates against a receiver that does not.
Sign: Every delivery fails after the secret moves into a deployment pipeline.Cause: The key is a byte string. A secret read from a file arrives with a trailing newline, a secret pasted into a dashboard field arrives with a leading space, and a receiver that hex-decodes a secret the sender treated as text keys the HMAC with other bytes. Print the key length on both sides before suspecting the body.
Sign: The comparison is a plain equality operator and every test still passes.Cause: It does pass. A string comparison returns the same verdict as a constant-time one on every input, so no request you can send separates them. This is a code review finding rather than a test finding, and the GitHub page measures what a timing attempt on your own machine actually shows.

What to check next

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.

intermediate8 minpublished updated Maks Verny