2 min read
Signing & verification
Every webhook delivery is signed with HMAC-SHA256 over the raw request body using your webhook's secret. Verify before you trust.
Header
X-Formspring-Signature: 5b8ad...e3
Hex-encoded HMAC-SHA256 digest. Compare in constant time.
Verify in Node.js
import { createHmac } from 'node:crypto';
export default function handler(req, res) {
const sig = req.headers['x-formspring-signature'];
const expected = createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(req.rawBody)
.digest('hex');
if (
sig.length !== expected.length ||
!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
) {
return res.status(401).end();
}
// safe to use req.body now
}
Verify in PHP
$sig = $request->header('X-Formspring-Signature');
$expected = hash_hmac('sha256', $request->getContent(), env('WEBHOOK_SECRET'));
if (! hash_equals($expected, $sig)) {
abort(401);
}
Verify in Python
import hmac, hashlib
sig = request.headers["X-Formspring-Signature"]
expected = hmac.new(
secret.encode(), body_bytes, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, sig):
return 401
Verify in Ruby
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, raw_body)
unless Rack::Utils.secure_compare(expected, signature)
halt 401
end
Why constant-time compare
A naive == leaks information about how many bytes matched, which an attacker can use to forge signatures one byte at a time. Use timingSafeEqual / hash_equals / compare_digest / secure_compare instead.
Rotate the secret
To rotate, delete the webhook and create a new one. The new webhook gets a fresh secret; flip your service over to the new URL or update the existing destination URL via the API.