How to verify an HMAC webhook signature

How webhook signing works

Most webhook providers sign each delivery with a shared secret. The provider computes an HMAC (usually HMAC-SHA256) over the request body and puts the result in a header. Your server computes the same HMAC with the same secret. If the two values match, the payload is authentic and unmodified.

GitHub is a typical example. It sends X-Hub-Signature-256: sha256=<hex digest>, computed from your webhook secret and the payload. That header is only present if you configured a secret (GitHub docs). Other providers use different header names, encodings (hex or base64) and sometimes a timestamp, so always read your provider's docs for the exact format.

Step 1: Capture the raw body

This is where most implementations fail. The HMAC is computed over the exact bytes the sender sent. If your framework parses the JSON and you re-serialize it, whitespace and key order can change, and the signature won't match.

Step 2: Compute the expected signature

Python:

`python

import hmac, hashlib

def verify(secret: str, raw_body: bytes, header: str) -> bool:

expected = "sha256=" + hmac.new(

secret.encode("utf-8"), raw_body, hashlib.sha256

).hexdigest()

return hmac.compare_digest(expected, header)

`

Node.js:

`javascript

const crypto = require('crypto');

function verify(secret, rawBody, header) {

const expected = 'sha256=' +

crypto.createHmac('sha256', secret).update(rawBody).digest('hex');

const a = Buffer.from(expected);

const b = Buffer.from(header || '');

return a.length === b.length && crypto.timingSafeEqual(a, b);

}

`

Step 3: Compare in constant time

Don't use == or ===. A plain string comparison can return early at the first differing character, which leaks timing information. GitHub's docs specifically advise against plain equality. Use hmac.compare_digest (Python), crypto.timingSafeEqual (Node; it throws if the lengths differ, hence the length check above), hash_equals (PHP) or Rack::Utils.secure_compare (Ruby).

Step 4: Test it from the command line

You can reproduce a signature with OpenSSL to see whether your secret and body are right:

`bash

printf '%s' '{"action":"opened"}' | openssl dgst -sha256 -hmac 'my-secret'

`

Then send a signed test request to your local endpoint:

`bash

BODY='{"action":"opened"}'

SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac 'my-secret' | sed 's/^.* //')

curl -X POST http://localhost:3000/webhook \

-H 'Content-Type: application/json' \

-H "X-Hub-Signature-256: sha256=$SIG" \

--data "$BODY"

`

Use printf '%s', not echo. echo adds a trailing newline, which changes the bytes and the HMAC.

Common pitfalls

Debugging a mismatch

1. Log the length and first bytes of the raw body your handler received, and compare them with what the provider shows in its delivery log.

2. Recompute the HMAC from that exact body with openssl (step 4).

3. If OpenSSL matches the header but your code doesn't, your code is hashing different bytes or using a different secret.

Tools

I built Presign Pad for this: Sign an AWS request. See exactly why it fails. It runs in your browser and the free part works today.

Try it