How to Verify Webhook Signatures
What Are Webhook Signatures?
Webhook signatures are cryptographic hashes that prove a webhook request genuinely came from the claimed provider and has not been tampered with in transit. When a provider sends a webhook, it computes a hash of the request body using a shared secret key and includes this hash in a request header. Your server re-computes the hash and compares — if they match, the request is authentic.
The most common algorithm is HMAC-SHA256 (Hash-based Message Authentication Code with SHA-256). The provider and your application both know the secret key, and anyone without the key cannot forge a valid signature. This is the same principle used in JWT signing and API authentication.
Different providers implement signatures with slight variations: different header names, different hash algorithms (SHA1, SHA256, ECDSA), different encoding (hex vs base64), and different formats for the signed data. Understanding these differences is essential for implementing verification correctly.
How HMAC Verification Works
HMAC combines a cryptographic hash function with a secret key. The process is: take the message (the request body), combine it with the secret key using the HMAC algorithm, and produce a fixed-length hash. The same message and key always produce the same hash, but changing even one byte of the message or key produces a completely different hash.
Here is the basic verification flow in code:
const crypto = require('crypto');function verifyHMAC(body, receivedSignature, secret, algorithm = 'sha256') { const computed = crypto .createHmac(algorithm, secret) .update(body, 'utf8') .digest('hex');
// Use timing-safe comparison return crypto.timingSafeEqual( Buffer.from(receivedSignature), Buffer.from(computed) ); } ```
Two critical details: first, use the raw request body, not a parsed-and-re-serialized version. Re-serializing JSON can change whitespace, key order, or encoding, which changes the hash. Second, use timing-safe comparison to prevent timing attacks.
Provider-Specific Implementations
Each provider has its own signature format. Here are the key differences:
Stripe uses Stripe-Signature header with format t=timestamp,v1=hash. The signed payload is timestamp.body. Algorithm: HMAC-SHA256, hex-encoded.
GitHub uses X-Hub-Signature-256 header with format sha256=hash. The signed payload is the raw request body. Algorithm: HMAC-SHA256, hex-encoded.
Shopify uses X-Shopify-Hmac-Sha256 header containing the hash directly. Algorithm: HMAC-SHA256, base64-encoded (note: base64, not hex).
Slack uses X-Slack-Signature header with format v0=hash. The signed payload is v0:timestamp:body. Timestamp is in X-Slack-Request-Timestamp. Algorithm: HMAC-SHA256, hex-encoded.
Discord uses Ed25519 public-key signatures (not HMAC). Headers are X-Signature-Ed25519 and X-Signature-Timestamp. The signed payload is timestamp + body. This requires the tweetnacl library or similar for verification.
When debugging signature verification failures, use the ReqPour dashboard to see the exact headers and body the provider sent. This helps you identify issues like wrong encoding, missing timestamp, or body parsing changes.
Common Pitfalls
The most common cause of signature verification failure is body parsing. If your framework parses the JSON body before your verification code runs, re-serializing it may produce different bytes than the original. Always verify the signature against the raw, unparsed body.
In Express.js, use express.raw() to get the raw body:
app.post('/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
// req.body is a Buffer containing the raw bytes
const isValid = verifySignature(req.body, req.headers);
// ...
}
);Another common pitfall is encoding mismatches. Some providers use hex encoding for the signature, others use base64. Comparing a hex-encoded hash with a base64-encoded hash will never match, even if the underlying hash is identical. Check the provider's documentation carefully.
Timestamp validation is also important. Some providers include the timestamp in the signed payload (Stripe, Slack), so you must reconstruct the signed string correctly. Missing or miscalculating the timestamp component will produce a different hash.
Testing Signature Verification
Use ReqPour to test your signature verification implementation. First, send a real webhook through ReqPour and inspect the headers in the dashboard. Note the signature header value and the exact body content. Then, in your handler, log the computed hash and compare it with the received signature.
If verification fails, check these things in order: Is the body exactly the same bytes you are hashing? Is the secret key correct? Is the algorithm correct (SHA256 vs SHA1)? Is the encoding correct (hex vs base64)? Is the signed payload format correct (body only, or timestamp+body)?
ReqPour's replay feature is useful for testing verification. Capture a real webhook, implement your verification code, then replay the captured request. Since the request is identical (same body, same headers), your verification should pass. If it does not, the issue is in your verification code, not in the request.
For production, always verify signatures. During early development, you may want to skip verification temporarily (with a clear TODO to add it back) so you can focus on business logic. But never deploy to production without signature verification — it is your primary defense against webhook spoofing.
Related
Get started with ReqPour
Catch, inspect, and relay webhooks to localhost. Free to start, $3/mo for Pro.