The problem signatures solve
Without signatures, your webhook endpoint trusts whoever sends a request. An attacker who learns the URL can:
- Send fake "payment succeeded" events to upgrade their account.
- Send fake user creation, password reset, or membership events.
- Flood your endpoint with garbage to break downstream processing.
Authentication via shared API key in a header partly helps — but the key has to be stored, rotated, and exposed in every request. Signatures use the same shared secret but never transmit it; instead, the sender proves it knows the secret by signing each request body.
How HMAC verification works
HMAC (Hash-based Message Authentication Code) is the standard. The sender and receiver share a secret. For each request, the sender:
- Takes the request body (often plus a timestamp).
- Computes HMAC-SHA256(body, secret).
- Sends the result as a header (often
X-Signatureor similar).
The receiver does the same computation and compares. If the computed value matches what was sent, the request is authentic and unmodified.
The pitfalls
1. Verifying parsed JSON instead of raw bytes
Most frameworks helpfully parse the JSON body before your handler sees it. Re-serializing the parsed object and signing that doesn't produce the same bytes — key order, whitespace, and numeric formatting differ. Always verify against the raw request bytes before any parsing.
2. String comparison instead of timing-safe comparison
Comparing signatures with == leaks timing information that lets an attacker brute-force one byte at a time. Use a constant-time comparison function: hmac.compare_digest in Python, crypto.timingSafeEqual in Node, similar primitives in every language.
3. No timestamp / replay protection
An attacker who captures a single signed request can replay it indefinitely. Stripe, Slack, and others include a timestamp header (often inside the signed payload). The receiver should reject requests with timestamps older than 5 minutes. Store and check seen IDs for sensitive operations.
4. Logging the signature or body
Logs frequently leak. Avoid logging signatures (they're effectively bearer tokens for the request) and full request bodies. Log enough to debug; redact sensitive fields.
5. Using the wrong scheme
Different providers use slightly different formats. Stripe concatenates timestamp + "." + body. GitHub uses just the body with a specific HMAC encoding. Slack uses "v0:" + timestamp + ":" + body. Don't assume — read the docs for the specific provider.
6. Falling back to no verification "for testing"
A common pattern: skip verification when NODE_ENV !== 'production', or accept a special unsigned header in test mode. This often ships to production. Better: use a test-mode secret that you can rotate independently, and verify in all environments.
Secret management
The signing secret is the equivalent of a master password for your webhook endpoint. Treat it accordingly:
- Never commit it to source. Use environment variables or a secret manager.
- Rotate periodically. Most providers support multiple active secrets to allow zero-downtime rotation.
- Use a different secret per environment.
- If you suspect compromise, rotate immediately and audit recent events.
Verification pseudocode
function verifyWebhook(rawBody, header, secret):
parts = parse(header) # e.g. "t=1234567890,v1=abc..."
timestamp = parts.t
signature = parts.v1
if abs(now() - timestamp) > 300:
reject "stale"
signed = timestamp + "." + rawBody
expected = hmacSha256(secret, signed)
if not timingSafeEqual(signature, expected):
reject "invalid signature"
if hasSeenId(parts.id):
reject "replay"
markSeen(parts.id)
accept
Quick checklist
- Verify before parsing.
- Use timing-safe comparison.
- Reject stale timestamps.
- Track event IDs for replay protection on sensitive operations.
- Don't log the signature or full body.
- One secret per environment; rotate on schedule.
- Read the provider's spec — don't assume the scheme.