HMAC Webhook Verification: What Most Tutorials Get Wrong
You copy the webhook verification snippet from the docs, the tests pass, you ship it. Six months later someone replays an old event and something bad happens. Here's why.
This isn't a "here's what HMAC is" post. There are a thousand of those. This is about the specific, real mistakes that appear in production codebases written by developers who thought they'd implemented webhook verification correctly.
What HMAC Actually Does (Quick Version)
HMAC is a keyed hash. The provider signs the payload with a shared secret. You verify the signature matches before trusting the payload. Without it, anyone can POST to your webhook endpoint and trigger payment logic -- create fake orders, mark transactions as paid, trigger refunds.
Stripe, Adyen, GitHub, Twilio -- they all use variants of this pattern. The concept is simple. The implementation is where things quietly go wrong.
Mistake #1: Parsing the Body Before Verifying It
This is the big one. I've seen it in production codebases at multiple companies.
Here's the problem: JSON parsers normalize whitespace, can reorder keys (in some implementations), and change encoding. HMAC is computed over the exact byte sequence the provider sent. If you parse the payload to an object first, then re-serialize to verify, you may be hashing a different byte sequence than what was signed.
The wrong way:
# WRONG -- body is already parsed by the framework
@app.post("/webhook")
async def webhook(payload: PaymentEvent): # FastAPI auto-parsed this
verify_signature(payload.json(), request.headers["X-Signature"])
The right way:
# RIGHT -- read raw bytes first, parse after
@app.post("/webhook")
async def webhook(request: Request):
raw_body = await request.body() # bytes, untouched
verify_signature(raw_body, request.headers["X-Signature"])
payload = json.loads(raw_body) # parse AFTER verification
In Laravel:
// Use $request->getContent(), not $request->all()
$rawBody = $request->getContent();
The result of getting this wrong: verification fails silently, and somewhere in the debugging process, a developer disables signature checking "temporarily" because it "never works." I've seen that comment in code. // TODO: re-enable signature verification. The TODO never happens.
Mistake #2: No Replay Attack Protection
An attacker captures a valid signed webhook -- say, "payment succeeded." They replay it hours or days later to trigger the same logic again. The signature is valid. The payload hasn't been tampered with. Naive verification passes.
The fix is timestamp validation. Stripe includes a t=<unix_timestamp> in the signature header for exactly this reason. Reject any webhook where the timestamp is older than your tolerance window -- typically five minutes.
import time
def verify_timestamp(signature_header: str, tolerance: int = 300):
# Stripe format: t=1614556828,v1=abc123...
elements = dict(
pair.split("=", 1)
for pair in signature_header.split(",")
)
timestamp = int(elements["t"])
if abs(time.time() - timestamp) > tolerance:
raise ValueError("Webhook timestamp outside tolerance window")
$timestamp = /* extract from signature header */;
if (abs(time() - $timestamp) > 300) {
abort(403, 'Webhook timestamp outside tolerance window');
}
One gotcha: this requires your server clock to be reasonably in sync. If you're running containers, make sure NTP is configured. A clock that's drifted six minutes will reject every valid webhook.
This is a one-liner addition, but almost nobody includes it because the tutorials skip it.
Mistake #3: Using String Comparison Instead of Constant-Time Comparison
Regular string comparison (==) short-circuits on the first mismatched character. That means a comparison against the correct signature returns faster when the first characters match than when they don't. An attacker can statistically infer the correct signature by measuring response times.
Sounds theoretical. It's a known attack vector. And it's trivial to fix.
# Wrong
if computed_signature == provided_signature:
pass
# Right
import hmac
if hmac.compare_digest(computed_signature, provided_signature):
pass
In PHP:
// Wrong
if ($computed === $provided) { }
// Right
if (hash_equals($computed, $provided)) { }
Most Laravel developers already know hash_equals() from password comparison. They just forget it applies here too.
Mistake #4: Logging the Raw Payload
This one's less about security verification and more about what happens around it. Raw webhook payloads from payment providers often contain: card last four digits, billing addresses, customer emails, transaction amounts.
Logging these violates PCI DSS guidance and potentially GDPR. I've seen full webhook payloads dumped to application logs during development, left there through deployment, and sitting in a log aggregator with broad team access.
The fix: log the event type and event ID. That's it. Never the full payload.
import logging
logger = logging.getLogger(__name__)
# Right -- enough to debug, nothing sensitive
logger.info("Webhook received", extra={
"event_type": payload.get("type"),
"event_id": payload.get("id"),
})
# Wrong -- PII and financial data in your logs
logger.info(f"Webhook payload: {payload}")
Putting It All Together
Here's a complete, correct implementation. This is the "copy this" section.
Python (FastAPI):
import hashlib
import hmac
import json
import logging
import time
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
logger = logging.getLogger(__name__)
WEBHOOK_SECRET = "whsec_..." # from environment, not hardcoded
@app.post("/webhook")
async def handle_webhook(request: Request):
# 1. Read raw bytes
raw_body = await request.body()
signature_header = request.headers.get("Stripe-Signature", "")
# 2. Parse signature header
elements = dict(
pair.split("=", 1)
for pair in signature_header.split(",")
)
timestamp = int(elements.get("t", 0))
provided_signature = elements.get("v1", "")
# 3. Check replay window
if abs(time.time() - timestamp) > 300:
raise HTTPException(status_code=403, detail="Timestamp expired")
# 4. Compute expected signature
signed_payload = f"{timestamp}.{raw_body.decode()}"
expected = hmac.new(
WEBHOOK_SECRET.encode(),
signed_payload.encode(),
hashlib.sha256,
).hexdigest()
# 5. Constant-time comparison
if not hmac.compare_digest(expected, provided_signature):
raise HTTPException(status_code=403, detail="Invalid signature")
# 6. Parse after verification
payload = json.loads(raw_body)
# 7. Log safely
logger.info("Webhook verified", extra={
"event_type": payload.get("type"),
"event_id": payload.get("id"),
})
return {"status": "ok"}
PHP (Laravel):
// app/Http/Middleware/VerifyWebhookSignature.php
public function handle(Request $request, Closure $next)
{
$rawBody = $request->getContent();
$signatureHeader = $request->header('Stripe-Signature', '');
// Parse signature header
$elements = collect(explode(',', $signatureHeader))
->mapWithKeys(fn ($pair) => [
Str::before($pair, '=') => Str::after($pair, '='),
]);
$timestamp = (int) $elements->get('t', 0);
$providedSignature = $elements->get('v1', '');
// Check replay window
if (abs(time() - $timestamp) > 300) {
abort(403, 'Webhook timestamp expired');
}
// Compute expected signature
$signedPayload = "{$timestamp}.{$rawBody}";
$expected = hash_hmac('sha256', $signedPayload, config('services.stripe.webhook_secret'));
// Constant-time comparison
if (! hash_equals($expected, $providedSignature)) {
abort(403, 'Invalid webhook signature');
}
// Log safely
$payload = json_decode($rawBody, true);
Log::info('Webhook verified', [
'event_type' => $payload['type'] ?? 'unknown',
'event_id' => $payload['id'] ?? 'unknown',
]);
return $next($request);
}
The Checklist
- Read the raw body before any parsing or framework deserialization
- Validate the timestamp -- reject anything older than five minutes
- Use constant-time comparison --
hmac.compare_digest()in Python,hash_equals()in PHP - Log event type and ID only -- never the full payload
These four things take maybe 30 lines of code. They're the difference between a webhook endpoint that looks secure and one that actually is. The concepts apply across providers -- Stripe, GitHub, Twilio, Adyen all use variants of the same pattern. Get it right once, and you've got a template for every integration that follows.