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.
- Express: use
express.raw({ type: 'application/json' })on the webhook route, notexpress.json(). - Flask: use
request.get_data(), notrequest.json. - Serverless platforms: check whether the body arrives base64-encoded, and decode it before hashing.
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
- Hashing parsed JSON instead of raw bytes. This is the number-one cause of failures (see step 1).
- Forgetting the prefix. GitHub's value starts with
sha256=. Either include it in your expected string or strip it from the header. - Hex vs base64. Some providers send base64. If your digest looks completely different in length, check the encoding.
- Wrong secret. Separate test and live secrets, or a secret with trailing whitespace from a
.envfile, produce mismatches that look identical to tampering. - Encoding. GitHub notes that payloads can contain Unicode, so handle the body and secret as UTF-8 where your language asks for an encoding.
- Replay. A valid signature doesn't make a request fresh. If the provider signs a timestamp, reject old deliveries. Either way, store delivery IDs and ignore duplicates.
- Logging the secret. Log the computed and received signatures while debugging, never the secret itself.
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.