Webhook Signature Verification: How to Verify Webhooks Safely (HMAC and Public Key)
By Chris Moen • Published 2026-02-24
Learn webhook signature verification fast: what it is, how it works, and a copy‑paste checklist. Includes HMAC examples (Node.js, Python), timestamp replay protection, rotation, and failure handling.
Quick answer: webhook signature verification
Webhook signature verification confirms the request’s origin and integrity before you process it. Compute an HMAC (shared secret) or verify a digital signature (public key) over the exact raw request body, often combined with a timestamp, and compare using a constant-time check. Reject if headers are missing, the signature doesn’t match, or the timestamp is outside your allowed window. Only parse and handle the payload after these checks pass.
At-a-glance checklist
- Capture raw request bytes on the webhook route (do not pre-parse JSON).
- Read required headers (signature, timestamp, scheme/version as documented).
- Build the exact base string per provider docs (raw body, often prefixed by timestamp).
- Verify with the correct algorithm and encoding (HMAC SHA-256 is common; some use public keys).
- Enforce a short timestamp window (for example, 5 minutes) to reduce replay risk.
- Compare signatures in constant time; reject on missing/unknown headers.
- Use idempotent handlers; drop duplicate event IDs when provided.
- Log minimally on failure; never echo secrets or full signature headers.
- Plan secret/key rotation; support overlap during cutover.
- Monitor failure rates and verification latency; alert on spikes.
How webhook signature verification works
Providers prove authenticity by signing the webhook payload:
- HMAC: You and the provider share a secret. You recompute the HMAC over the base string (often timestamp + raw body) and compare.
- Public key: The provider signs with its private key. You verify with their public key, usually identified by a key ID.
This protects both integrity (unchanged payload) and source (authentic sender).
Implementation: secure default workflow
- Receive the request and capture raw bytes on the webhook route.
- Read signature/timestamp headers and the scheme/version header if present.
- Construct the exact base string per the provider’s documentation.
- Verify the signature (HMAC or public key) and enforce a timestamp age limit.
- Perform a constant-time equality check against the expected signature.
- On success, parse JSON and run business logic; on failure, return 401 or 400.
Provider headers you might see
Expect a signature header and often a timestamp and version/scheme header. Names vary by provider. Examples from documentation include:
- X-Affirm-Signature and an endpoint secret
- Paddle-Signature and an endpoint secret
- Persona-Signature with an HMAC
Always follow your provider’s docs for header names, base string format, and encoding.
Computing and comparing the signature
- Use the raw body bytes exactly as received.
- Build the base string exactly as specified (for example, timestamp.body).
- Match the algorithm and output encoding (hex vs base64) used by the provider.
- Use a constant-time comparison and reject on missing or unknown headers.
Replay protection
- Require a timestamp header and reject requests older than your allowed window.
- If the provider sends an event ID, store recent IDs and drop duplicates.
- Apply your own idempotency keys if relevant to your handler.
Failures, retries, and idempotency
- On verification failure, return 401 Unauthorized or 400 Bad Request and do not process the payload.
- For transient issues (for example, secret store unavailable), return 503 Service Unavailable.
- Make handlers idempotent so re-deliveries or duplicates are safe.
Secret rotation and key management
- Plan rotation and accept multiple signatures or secrets during cutover, when supported.
- Store secrets in a secure secret manager and tightly scope access.
- Never log secrets or full signature headers; audit access and use.
Testing and monitoring
- Test: known good payloads, tampered payloads, wrong secrets, and clock skew.
- Monitor: verification failures, source IPs/user agents on failures, and verification latency.
- Alert: spikes in failures or unusual sources.
Supporting multiple webhook providers
- Route by endpoint path or a provider header and delegate to a provider-specific verifier.
- Maintain per-provider: secret or public key, header names, base string, encoding, and time window.
Common mistakes to avoid
- Processing before verification (always verify first).
- Relying only on IP allowlists.
- Parsing JSON before computing the HMAC.
- Echoing headers or secrets in logs.
- Skipping rotation planning.
- Returning 200 OK on failed checks.
Minimal example: HMAC with Node.js (Express)
This example shows a typical HMAC flow with raw body, timestamp check, and constant-time compare. Replace header names, base string, and encoding with your provider’s docs.
import express from "express"; import crypto from "crypto";
const app = express();
// Capture raw body for HMAC app.use("/webhooks/provider", express.raw({ type: "/", limit: "1mb" }));
function timingSafeEquals(a, b) { const ba = Buffer.from(a); const bb = Buffer.from(b); if (ba.length !== bb.length) return false; return crypto.timingSafeEqual(ba, bb); }
function verifySignature({ rawBody, signatureHeader, timestampHeader, secret }) { if (!signatureHeader || !timestampHeader) return false;
// Example base string. Check your provider docs. // Some providers sign: `${timestamp}.${rawBody}` const baseString = `${timestampHeader}.${rawBody.toString("utf8")}`;
const expected = crypto .createHmac("sha256", secret) .update(baseString, "utf8") .digest("hex"); // or "base64" per provider
return timingSafeEquals(signatureHeader, expected); }
app.post("/webhooks/provider", (req, res) => { const secret = process.env.PROVIDER_WEBHOOK_SECRET; const sig = req.get("X-Provider-Signature"); const ts = req.get("X-Provider-Timestamp");
// Check age to reduce replay risk const now = Math.floor(Date.now() / 1000); const age = Math.abs(now - parseInt(ts || "0", 10)); const maxAgeSecs = 300; // set per your policy and provider guidance if (!ts || Number.isNaN(age) || age > maxAgeSecs) { return res.status(401).send("Invalid timestamp"); }
const ok = verifySignature({ rawBody: req.body, signatureHeader: sig, timestampHeader: ts, secret });
if (!ok) { return res.status(401).send("Invalid signature"); }
// Safe to parse and process const event = JSON.parse(req.body.toString("utf8")); // handle event... res.status(200).send("ok"); });
app.listen(3000);
Minimal example: HMAC with Python (Flask)
This mirrors the same steps. Use raw data, HMAC SHA-256, and constant-time compare.
import os import hmac import hashlib import time from flask import Flask, request, abort
app = Flask(__name__)
def timing_safe_equals(a: str, b: str) -> bool: return hmac.compare_digest(a, b)
@app.route("/webhooks/provider", methods=["POST"]) def webhook(): raw = request.get_data(cache=False, as_text=False) sig = request.headers.get("X-Provider-Signature") ts = request.headers.get("X-Provider-Timestamp")
if not sig or not ts: abort(401)
try: ts_int = int(ts) except ValueError: abort(401)
now = int(time.time()) if abs(now - ts_int) > 300: abort(401)
secret = bytes(os.environ["PROVIDER_WEBHOOK_SECRET"], "utf-8") base_string = f"{ts}.{raw.decode('utf-8')}".encode("utf-8") expected = hmac.new(secret, base_string, hashlib.sha256).hexdigest()
if not timing_safe_equals(sig, expected): abort(401)
Safe to parse
event = request.get_json(force=True, silent=False)
handle event...
return "ok", 200
Note: Some providers sign only the raw body. Others sign a versioned scheme with timestamp. Use the provider docs to set base_string, headers, and output encoding.
SDKs and public key verification
- If your provider offers an official SDK, use it. It typically handles header parsing, base strings, and version changes.
- For asymmetric signatures, fetch and cache the public keys, verify using the documented algorithm, and handle rotation by key ID.
Where Breyta fits
Breyta is a workflow and agent orchestration platform for coding agents. If your automations start with a webhook, you can run signature verification as the first step in a deterministic workflow, then orchestrate your coding agent to process the event. Breyta provides deterministic runtime behavior, explicit approvals and waits when needed, versioned flow definitions, and clear run history—so you can operate long-running, multi-step webhook-driven jobs with confidence.
FAQ
What if my framework parses JSON before I can read the body?
Disable automatic JSON parsing for the webhook route. Read raw bytes first. Parse only after verification succeeds.
Can I rely on IP allowlists instead of signatures?
No. Use signature verification. IP ranges can change and IP checks can fail open.
What status code should I return on a bad signature?
Return 401 Unauthorized. You can also use 400 Bad Request. Do not return 200 OK on failed checks.
Do I need to store webhook payloads?
Store only what you need for troubleshooting and idempotency. Avoid storing full payloads with sensitive data.
How often should I rotate secrets?
Set a rotation policy that fits your risk profile and provider guidance. Support overlap during the change to avoid downtime.