Webhook Verification Guide: HMAC Security with Real-World Examples
Learn how to verify webhook signatures using HMAC, common pitfalls to avoid, and production-ready code examples from real-world implementations.
Why Webhook Verification Is Non-Negotiable
Let me start with a story: Early in my career, I built a webhook endpoint to process payment notifications. I didn't verify signatures — I just assumed any request to that endpoint was legitimate.
Someone figured this out and sent fake "payment.success" requests. We almost shipped products to customers without receiving payment. It was a close call — and entirely preventable.
Here's the harsh truth: **Anyone can send a POST request to your webhook endpoint.** The internet is full of people who will try to exploit this.
Verifying the webhook signature ensures three critical things:
1. **Authenticity**: The request actually came from the provider you expect, not an imposter
2. **Integrity**: The payload wasn't tampered with in transit — what they sent is what you received
3. **Replay protection** (when implemented): An attacker can't just record a valid request and send it again later
If you're accepting webhooks without verification, you're playing with fire. Fortunately, HMAC verification is straightforward once you understand how it works — and I'll show you exactly how to implement it.
How HMAC Webhook Verification Works
HMAC (Hash-based Message Authentication Code) is the standard way to verify webhooks. It's simple, secure, and widely supported by almost every webhook provider.
Here's the big picture:
**The Provider (e.g., Stripe, GitHub, Shopify) does this:**
1. They have a shared secret that they give you (and only you)
2. When an event happens, they take the raw webhook payload
3. They compute the HMAC signature of that payload using the secret
4. They send the request to you with that signature in a header
**You (the Recipient) do this:**
1. You receive the request
2. You extract the signature from the header
3. You compute the HMAC signature of the raw request body using the same shared secret
4. You compare your computed signature with the one they sent
5. If they match (using a constant-time comparison!), the request is valid — process it!
The magic here is that only someone with the secret can create a valid signature. Since the secret is never sent over the wire, attackers can't fake it.
Think of it like this: Imagine you and a friend have a secret handshake. If someone comes up and does the handshake perfectly, you know it's them — even if you can't see their face. That's what HMAC verification does.
Step-by-Step: Implementing Webhook Verification
Let's walk through a concrete example with actual code. This is the exact process I use when implementing webhook verification in production.
**Scenario:**
- You receive a webhook from a payment provider
- Header: `X-Webhook-Signature: sha256=abc123def456...`
- Body: `{"event": "payment.success", "id": "pay_123", "amount": 9999}`
- Your shared secret (from the provider): "whsec_mysecretkey123"
**Step 1: Extract the Signature from the Header**
First, get the signature from the header. Most providers include the algorithm (e.g., "sha256=") as a prefix.
**Step 2: Get the Raw Request Body**
This is crucial — you need the exact raw bytes as they were sent. Do NOT use parsed JSON! The smallest difference (extra space, different line endings) will change the signature.
**Step 3: Compute the Expected Signature**
Compute HMAC-SHA256 of the raw body using your secret. Most providers use SHA-256, but check their docs!
**Step 4: Compare Using Constant-Time Comparison**
This is important for security — never use regular `==` comparison! Use a constant-time equality function to prevent timing attacks.
**Step 5: Process or Reject**
If the signatures match: process the webhook!
If they don't: reject the request with a 401 or 403 status.
**In Production:** I usually log signature mismatches (without logging the full payload) to debug issues — but don't log the secret or the signatures in production!
Production-Ready Code Examples
Let's look at actual code you can use. I've implemented this in multiple languages, and these are my go-to snippets.
**Node.js (Express):**
import crypto from 'crypto';
import express from 'express';
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
// Make sure to get the raw body, not parsed JSON!
app.use(express.raw({ type: 'application/json' }));
app.post('/webhook', (req, res) => {
const signatureHeader = req.headers['x-webhook-signature'];
if (!signatureHeader) {
return res.status(401).send('Missing signature header');
}
// Extract the signature (remove "sha256=" prefix)
const receivedSignature = signatureHeader.replace('sha256=', '');
// Compute expected signature
const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
hmac.update(req.body); // req.body is the raw buffer!
const expectedSignature = hmac.digest('hex');
// Constant-time comparison
const isMatch = crypto.timingSafeEqual(
Buffer.from(receivedSignature, 'hex'),
Buffer.from(expectedSignature, 'hex')
);
if (!isMatch) {
return res.status(401).send('Invalid signature');
}
// Now it's safe to parse the JSON
const payload = JSON.parse(req.body);
console.log('Valid webhook:', payload.event);
// Process the webhook...
res.status(200).send('OK');
}); **Python:**
import hmac
import hashlib
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = os.environ['WEBHOOK_SECRET'].encode()
@app.route('/webhook', methods=['POST'])
def webhook():
signature_header = request.headers.get('X-Webhook-Signature')
if not signature_header:
abort(401, 'Missing signature header')
received_signature = signature_header.replace('sha256=', '')
# Get raw body
raw_body = request.get_data()
# Compute expected signature
hmac_obj = hmac.new(WEBHOOK_SECRET, raw_body, hashlib.sha256)
expected_signature = hmac_obj.hexdigest()
# Constant-time comparison
if not hmac.compare_digest(received_signature, expected_signature):
abort(401, 'Invalid signature')
# Process the webhook...
return 'OK', 200 Important notes about these examples:
- The secret comes from an environment variable — never hardcode it!
- We use the raw body, not parsed JSON
- We use constant-time comparison (crypto.timingSafeEqual, hmac.compare_digest)
You can use our HMAC Generator to create test signatures while implementing this — it's great for debugging!
Common Webhook Verification Mistakes (I've Made All These)
I've implemented webhook verification many times, and I've made every mistake in the book. Here's what to watch out for:
**1. Using Parsed JSON Instead of the Raw Body**
This is the #1 mistake! When you parse JSON and then stringify it again, you get different whitespace, different ordering, different escaping. The signature won't match. Always use the raw bytes!
**2. Using Regular Equality Instead of Constant-Time Comparison**
If you use `==`, an attacker can measure how long the comparison takes and figure out the signature one character at a time. Use the timing-safe functions your language provides.
**3. Hardcoding Secrets Instead of Using Environment Variables**
Never commit secrets to git! Use environment variables or a secrets manager. I once accidentally committed a webhook secret — we had to rotate it immediately.
**4. Forgetting to Handle Multiple Signatures**
Some providers send multiple signatures when they're rotating secrets. Your code should check all of them and accept if any match.
**5. Not Checking Timestamps for Replay Protection**
Many providers include a timestamp in the header (e.g., `X-Webhook-Timestamp`). You should check that the timestamp is recent (e.g., within 5 minutes) to prevent replay attacks.
**6. Not Handling Different Encodings**
Is the signature hex? Base64? Our Base64 Decoder can help here. Check the documentation!
**7. Processing the Webhook Before Verifying**
Don't do ANYTHING with the payload until you've verified the signature. Not even parsing it more than necessary for verification.
The good news: Most of these are easy to avoid once you know about them!
How to Test Webhook Verification
Testing is crucial — you don't want to find out your verification is broken when a real attack happens. Here's my test checklist:
**Test Cases You Must Cover:**
1. ✅ **Valid signature should succeed** — This is the happy path
2. ❌ **Invalid signature should fail** — Change one character in the signature
3. ❌ **Modified payload should fail** — Change one character in the body
4. ❌ **Wrong secret should fail** — Use a different secret to compute the signature
5. ❌ **Missing signature header should fail** — Don't send the header at all
6. ❌ **Wrong algorithm should fail** — If the header says "sha1=" but you expect "sha256="
7. ❌ **Old timestamp should fail** (if you implement replay protection)
**How to Test:**
Use our HMAC Generator to create valid signatures for your test payloads. You can also use tools like curl or Postman to send test requests.
**Example test with curl:**
# Create a test payload
cat > payload.json << EOF
{
"event": "payment.success",
"id": "pay_123"
}
EOF
# Compute the signature using our tool, then:
curl -X POST https://yourapp.com/webhook \
-H "Content-Type: application/json" \
-H "X-Webhook-Signature: sha256=YOUR_COMPUTED_SIGNATURE" \
-d @payload.json **Automated Tests:** I always write unit tests for webhook verification. It gives me confidence that refactoring won't break the security.
**In Production:** I also log signature mismatches (without logging sensitive data) — this helps debug integration issues and alerts me to potential attacks.
Provider-Specific Notes: Stripe, GitHub, Slack, and More
Different providers have slightly different implementations. Here's what you need to know for common ones:
**Stripe:**
- They use `Stripe-Signature` header with a timestamp and multiple signatures
- They have official libraries that handle verification for you — use them!
- Replay protection built-in using the timestamp
**GitHub:**
- They use `X-Hub-Signature-256` header (use this instead of the old SHA-1 one)
- Format is `sha256=abc123...`
**Slack:**
- They use `X-Slack-Signature` and `X-Slack-Request-Timestamp`
- The signature is computed on `v0:timestamp:body`
- Check that the timestamp is within 5 minutes
**Shopify:**
- They use `X-Shopify-Hmac-Sha256` header
- The signature is Base64 encoded, not hex — use our Base64 Decoder!
**General Advice:**
Always read the provider's webhook documentation carefully. They usually have examples in multiple languages — use them as a starting point. And use our HMAC Generator to compute test signatures while debugging!
Try These Tools
Open the tools that go with this tutorial and practice what you learned.
Keep Learning
YAML vs JSON: A Practical Guide for Developers
Compare YAML and JSON: syntax, use cases, tradeoffs, and practical recommendations from real-world experience with APIs, configs, and DevOps.
Hash Generator Guide: MD5, SHA-1, SHA-256, SHA-512 Explained
Understand cryptographic hashes, when to use each algorithm, practical examples, and common pitfalls from real-world experience.