Verifying Webhook Signatures
Introduction
Anyone can POST to a public URL. The Signature header is how you verify a message genuinely came from AAArdvark – it’s a fingerprint of the exact message body, created using a secret only you and AAArdvark know. Recreate that fingerprint on your side, check it matches, and you have proof the message is real. This is the one part of webhooks really worth getting right.
How the signature is built
The recipe is the same in every language:
- Method: HMAC-SHA256.
- Key: your webhook secret (the hex string from the configuration screen).
- Message: the raw request body, exactly as it arrived – byte for byte.
- Result: a lowercase hexadecimal string, which should match the
Signatureheader.
The one thing that trips everyone up. Build the fingerprint from the raw bytes of the body, not from JSON you’ve parsed and turned back into text. Re-stringifying can quietly change spacing, key order, or how characters are escaped – and any of those will make the fingerprints disagree even though nothing’s wrong. Nearly every “verification keeps failing” report comes down to this. The samples below all read the raw body for exactly this reason.
Node.js / Express
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.WEBHOOK_SECRET;
// Important: use express.raw so we get the body as bytes, not as parsed JSON.
app.post('/webhooks/aaardvark',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.header('Signature');
if (!signature) return res.status(401).send('missing signature');
const expected = crypto
.createHmac('sha256', SECRET)
.update(req.body) // req.body is a Buffer of the raw bytes
.digest('hex');
// Constant-time comparison to avoid timing attacks
const ok = signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.status(401).send('invalid signature');
// Verified — safe to parse and handle
const event = JSON.parse(req.body.toString('utf8'));
// ... handle event
res.status(200).send('ok');
}
);
PHP / Laravel
<?php
use Illuminate\Http\Request;
Route::post('/webhooks/aaardvark', function (Request $request) {
$secret = env('WEBHOOK_SECRET');
$signature = $request->header('Signature');
if (! $signature) {
return response('missing signature', 401);
}
// Important: use the raw body, not Laravel's parsed json input
$rawBody = $request->getContent();
$expected = hash_hmac('sha256', $rawBody, $secret);
if (! hash_equals($expected, $signature)) {
return response('invalid signature', 401);
}
$event = json_decode($rawBody, true);
// ... handle event
return response('ok', 200);
});
Python / Flask
import hmac, hashlib, os, json
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['WEBHOOK_SECRET'].encode('utf-8')
@app.post('/webhooks/aaardvark')
def aaardvark_webhook():
signature = request.headers.get('Signature')
if not signature:
abort(401, 'missing signature')
# request.get_data() returns the raw body as bytes
raw = request.get_data()
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401, 'invalid signature')
event = json.loads(raw.decode('utf-8'))
# ... handle event
return ('ok', 200)
Related Guides
-
Outgoing Webhooks
-
Setting Up an Outgoing Webhook
-
What’s Inside Each Webhook Message
-
Receiving Webhook Messages Reliably