Verifying Webhook Signatures
An attacker found our webhook URL in a client-side error log and POSTed a fake order.completed event. Our handler processed it — no signature check, no authentication. One line of middleware verifying HMAC-SHA256 would have rejected it. We added signature verification that day and rotated the webhook secret as a precaution.
HMAC signature scheme
Most providers use HMAC-SHA256:
signature = HMAC-SHA256(secret, timestamp + "." + raw_body)
The sender includes the signature and timestamp in headers. The receiver recomputes and compares.
Stripe-style verification
import hmac
import hashlib
import time
def verify_stripe_signature(payload: bytes, sig_header: str, secret: str, tolerance: int = 300):
elements = dict(item.split("=", 1) for item in sig_header.split(","))
timestamp = int(elements["t"])
received_sig = elements["v1"]
# Reject stale timestamps
if abs(time.time() - timestamp) > tolerance:
raise WebhookError("Timestamp outside tolerance window")
# Compute expected signature
signed_payload = f"{timestamp}.".encode() + payload
expected_sig = hmac.new(
secret.encode(), signed_payload, hashlib.sha256
).hexdigest()
# Constant-time comparison
if not hmac.compare_digest(expected_sig, received_sig):
raise WebhookError("Invalid signature")
return True
Generic HMAC verification
For custom webhooks or providers using simpler schemes:
def verify_hmac_signature(payload: bytes, signature: str, secret: str):
expected = hmac.new(
secret.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)
Header format varies by provider:
| Provider | Header | Format |
|---|---|---|
| Stripe | Stripe-Signature |
t=timestamp,v1=sig |
| GitHub | X-Hub-Signature-256 |
sha256=hex_sig |
| Shopify | X-Shopify-Hmac-Sha256 |
base64 sig |
| Svix | svix-signature |
v1,base64_sig |
Express middleware
function webhookVerifier(secret) {
return (req, res, next) => {
const signature = req.headers['x-webhook-signature'];
const timestamp = req.headers['x-webhook-timestamp'];
const rawBody = req.rawBody; // capture before JSON parsing
if (!signature || !timestamp) {
return res.status(401).json({ error: 'Missing signature headers' });
}
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp));
if (age > 300) {
return res.status(401).json({ error: 'Timestamp too old' });
}
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
return res.status(401).json({ error: 'Invalid signature' });
}
next();
};
}
// Capture raw body before JSON middleware
app.use('/webhooks', express.raw({ type: 'application/json' }));
app.use('/webhooks', webhookVerifier(process.env.WEBHOOK_SECRET));
app.use('/webhooks', (req, res, next) => {
req.body = JSON.parse(req.body);
next();
});
Raw body capture
Framework-specific raw body access:
# FastAPI
@app.post("/webhooks")
async def handle_webhook(request: Request):
body = await request.body() # raw bytes
signature = request.headers.get("X-Webhook-Signature")
verify_hmac(body, signature, SECRET)
payload = json.loads(body)
# Rails — skip params parsing for webhook routes
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def create
payload = request.raw_post
verify_signature(payload, request.headers["X-Signature"])
process(JSON.parse(payload))
end
end
Secret management
- Generate secrets with
openssl rand -hex 32 - Store in a secrets manager, not environment variables in code
- Support secret rotation with dual-secret verification during transition
- Rotate immediately if a secret is exposed
- Use different secrets per webhook endpoint
def verify_with_rotation(payload, signature, timestamp):
for secret in [current_secret, previous_secret]:
try:
return verify_signature(payload, signature, secret, timestamp)
except WebhookError:
continue
raise WebhookError("No valid secret matched")
Security checklist
- Verify signature on every request — no exceptions for "testing"
- Use raw body bytes — never re-serialize parsed JSON
- Validate timestamp — reject replays outside tolerance window
- Constant-time comparison — use
hmac.compare_digestorcrypto.timingSafeEqual - Return generic 401 — don't leak whether timestamp or signature failed
- Log verification failures — alert on repeated failures from same IP
- Rotate secrets — support dual-secret during rotation windows
Clock skew tolerance
Set timestamp tolerance to 5 minutes (300 seconds) — enough for minor clock drift, short enough to limit replay windows. Log rejected timestamps with the delta for debugging NTP issues on receiver servers.
Testing signature verification
Generate test vectors in CI:
def test_signature_roundtrip():
payload = b'{"event": "test"}'
ts = str(int(time.time()))
sig = compute_signature(payload, ts, TEST_SECRET)
assert verify(payload, f"t={ts},v1={sig}", TEST_SECRET)
Measuring success in production
Deploy changes behind feature flags when possible so you can compare metrics between control and treatment groups. Use Real User Monitoring to capture performance data from actual devices and network conditions — lab tools alone miss the long tail of user experiences. Set up alerts for regressions: a 10% LCP increase week-over-week warrants investigation before it hits CrUX.
Document your baseline metrics before making changes. Performance work without measurement is guesswork. Share results with the team — concrete numbers ("LCP improved 800ms on mobile") build support for continued investment in web performance and reliability.
Review changes quarterly. Browser updates, new API support, and traffic pattern shifts can obsolete previous optimizations or create new opportunities. What worked in 2024 may not be the best approach in 2026.
Additional production considerations
Teams often underestimate the maintenance cost of performance optimizations. Automate what you can: CI bundle budgets, Lighthouse CI on PRs, and RUM dashboards that alert on regressions. Manual audits don't scale past a handful of pages.
Security and performance intersect more than teams expect. Third-party scripts that hurt INP also expand your attack surface. Self-hosting fonts and critical assets reduces both latency and supply-chain risk. Review every external dependency quarterly — remove what you no longer need.
Accessibility and performance share goals: semantic HTML helps screen readers and gives the browser better rendering hints. Native elements like dialog, popover, and details reduce JavaScript while improving accessibility. Prefer platform features over custom implementations when they meet your requirements.
Mobile users dominate traffic for most sites. Test on real mid-tier Android hardware, not just desktop Chrome. Simulated throttling in DevTools approximates network conditions but not CPU constraints. A fix that helps desktop may be invisible on mobile if the bottleneck is JavaScript execution, not network.
Collaborate with backend teams on TTFB and API response times. Frontend optimizations can't fix a 2-second server response. Set SLAs for API endpoints that feed critical pages and measure them in the same RUM pipeline as Core Web Vitals.
Resources
- Stripe webhook signatures
- GitHub webhook validation
- Svix webhook verification library
- OWASP Webhook Security
- Shopify HMAC verification
Frequently asked questions
Why is webhook signature verification necessary?
Webhook endpoints are public URLs. Without signature verification, anyone who discovers the URL can send fake events — creating fraudulent orders, triggering refunds, or modifying account state. Signatures prove the payload came from the expected sender and wasn't modified in transit. Always verify before processing any webhook payload.
Should I verify the signature before or after parsing the JSON body?
Verify before parsing. Read the raw request body as bytes, compute the expected signature, and compare. Then parse JSON. If you parse first and re-serialize, whitespace and key ordering differences will cause signature mismatches. Most frameworks let you access the raw body before JSON middleware processes it.
How do timestamp checks prevent replay attacks?
Include a timestamp in the signed payload. Reject webhooks with timestamps older than your tolerance window (typically 5 minutes). An attacker who captures a valid webhook cannot replay it after the window expires. Combine timestamp validation with idempotency keys for defense in depth against both replay and duplicate delivery.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →