Blog

Webhook Signature Verification: HMAC-SHA256 the Right Way

Erwan Prost

Erwan Prost

· 14 min read · Updated

Webhook signature verification is the mechanism that proves an inbound webhook POST actually came from the producer you trust and was not modified in transit. The producer computes a keyed hash (an HMAC) over the exact bytes of the request body using a secret only the two parties share, sends that hash in a header, and your endpoint recomputes the same hash and compares them in constant time. If the two values match, the request is authentic and intact. If they do not, you reject it before parsing a single field. This post covers that mechanism end to end. It explains why unsigned webhooks are exploitable, exactly how HMAC-SHA256 signing and verification work step by step, the four mistakes that quietly defeat verification, copy-pasteable verified handlers in Node, Python, and Go, and what else to layer on by threat model. It does not re-explain how to register a webhook or wire a full listening pipeline. That is the job of the social listening webhooks guide and the broader unified social inbox API pillar this post sits under.

What is webhook signature verification and why are unsigned webhooks exploitable?

Webhook signature verification is authentication of an inbound HTTP callback using a cryptographic proof attached to the request, rather than trusting the request because it arrived at the right URL. A webhook endpoint is a public HTTPS URL: it has to be reachable by the producer's servers, which means it is reachable by anyone who learns or guesses it. Without verification, your handler treats every POST as genuine, so the only thing standing between an attacker and your processing logic is the obscurity of a URL. Obscurity is not a security control.

Reason through the attack surface concretely. An unsigned endpoint that creates a support ticket on comment.received can be flooded with fabricated events. An endpoint that triggers an automated reply can be coerced into replying to messages that never existed. An endpoint that writes to a database on review.received can be fed forged reviews with attacker-chosen text and ratings. None of these require breaking TLS or compromising the producer. They only require the attacker to send a plausible JSON body to a URL, which is trivial once the URL leaks through logs, a proxy, a browser extension, or a misconfigured error report.

Signature verification closes this by binding every request to a secret that only the producer and your server hold. The signature is a function of the secret and the exact request body, so an attacker who does not have the secret cannot produce a value that passes your check, and an attacker who modifies the body invalidates a signature they cannot regenerate. That single property, origin authenticity plus body integrity from one shared key, is the entire job HMAC does for webhooks. It is the same primitive used by virtually every serious webhook producer because the alternative, an unauthenticated public endpoint that performs real work, is indefensible.

How does HMAC-SHA256 signing and verification work, step by step?

HMAC-SHA256 is a keyed hash: it takes a secret key and a message and produces a fixed-length digest that cannot be recomputed without the key. For webhooks the message is the raw request body and the key is the endpoint secret. The flow has five steps, three on the producer side and two on yours, and every step is load-bearing. Skipping or reordering any of them is one of the classic failures covered in the next section.

HMAC-SHA256 webhook signature verification
Pipeline diagram. Stages: Sign raw body → POST + header → Recompute HMAC → Constant-time compare → 200 or 401.

Same secret, same raw bytes on both ends. The signature travels in a header; the body is never trusted until the recomputed HMAC matches in constant time.

Step one, the producer serializes the event into a JSON body and freezes those exact bytes. Step two, it computes signature = HMAC-SHA256(secret, rawBytes) and hex-encodes the digest. Step three, it sends the POST with the body unchanged and a header carrying the signature. SocialAPI.ai uses X-SocialAPI-Signature: sha256=<hmac-hex>, where the hash is HMAC-SHA256 over the raw body keyed by your per-endpoint secret. The sha256= prefix is a scheme label, not part of the digest, so a correct comparison either includes the prefix on both sides or strips it on both sides.

Step four, your endpoint reads the raw request bytes before any JSON middleware touches them, and recomputes HMAC-SHA256(secret, rawBytes) with the same secret. Step five, you compare your computed value against the header value using a constant-time equality function, not a normal string comparison. If they are equal, the request is authentic and unmodified, and you proceed. If they are not, you return 401 immediately and never parse the body. The order matters: verification is a gate in front of parsing, not a check you run after you have already deserialized and acted on the payload.

The four mistakes that break verification

Most broken webhook verification is not a wrong algorithm. It is a correct algorithm fed the wrong input or compared the wrong way. Four mistakes account for the overwhelming majority of failures in production handlers, and three of the four fail silently: the code looks right, passes a happy-path test, and is exploitable; the first one shows up in nearly every broken webhook integration we debug. Read each one as a specific thing to check in your own handler before you ship it.

MistakeWhy it breaks verificationThe fix
Verifying a reparsed body, not the raw bytesJSON parse then re-serialize changes key order, whitespace, and unicode escaping, so your recomputed HMAC is over different bytes than the producer signed. Verification fails for legitimate requests, or you disable it to make tests pass.Read the raw request body before any JSON middleware and HMAC exactly those bytes. Parse only after the signature passes.
Using == instead of a constant-time compareNaive string or byte equality short-circuits on the first differing character. The tiny timing difference is measurable over many requests and lets an attacker recover a valid signature byte by byte.Compare with timingSafeEqual (Node), hmac.compare_digest (Python), or hmac.Equal (Go). Equalize length first so the compare itself does not leak length.
One shared secret across every endpointA single global secret means a leak anywhere forces rotation everywhere, and one compromised consumer can forge requests for all of them. Blast radius is the whole system instead of one endpoint.Use a distinct per-endpoint secret. SocialAPI.ai issues a unique secret per registered webhook so you can rotate one without touching the others.
No timestamp or replay windowA valid signed request captured once stays valid forever, so an attacker who records a single legitimate delivery can replay it indefinitely. HMAC proves authenticity, not freshness.Bind a timestamp into what you verify and reject deliveries outside a short window, and deduplicate on a stable event id so a replay is a no-op.

The first mistake is the most common and the most insidious because it manifests as legitimate requests failing verification, which tempts developers to weaken or remove the check rather than fix the input. The body your framework hands you after JSON middleware is a re-serialization of the parse tree, not the bytes on the wire; reordered keys, normalized whitespace, and different escaping all produce a different HMAC. The rule is absolute: HMAC the exact bytes received, parse later. Every example in the next section reads the raw body explicitly for this reason.

Verifying a SocialAPI.ai webhook in Node, Python, and Go

These three handlers verify a SocialAPI.ai webhook the correct way: raw body, constant-time compare, reject before parse. SocialAPI.ai sends X-SocialAPI-Signature: sha256=<hmac-hex>, computed as HMAC-SHA256 of the raw request body keyed by the per-endpoint secret you stored at registration. The shape is identical across languages; only the standard-library calls differ. Each one returns 401 on a verification failure and only then deserializes and enqueues the event.

javascript
import crypto from "crypto";
import express from "express";

const app = express();

function verify(secret, rawBody, header) {
  if (typeof header !== "string") return false;
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(header);
  // Length must match before timingSafeEqual, or it throws.
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

app.post(
  "/webhooks/socapi",
  express.raw({ type: "application/json" }), // raw bytes, not parsed JSON
  (req, res) => {
    const sig = req.headers["x-socialapi-signature"];
    if (!verify(process.env.SOCAPI_WEBHOOK_SECRET, req.body, sig)) {
      return res.status(401).send("Invalid signature");
    }
    res.sendStatus(200); // ack first
    const event = JSON.parse(req.body); // parse only after verifying
    queue.push(event); // dedupe downstream on event.data.id
  }
);
python
import hashlib
import hmac
import os

from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["SOCAPI_WEBHOOK_SECRET"].encode()


def verify(raw_body: bytes, header: str | None) -> bool:
    if not header:
        return False
    expected = "sha256=" + hmac.new(
        SECRET, raw_body, hashlib.sha256
    ).hexdigest()
    # compare_digest is constant time and length-safe.
    return hmac.compare_digest(expected, header)


@app.post("/webhooks/socapi")
def handle():
    raw = request.get_data()  # raw bytes, before any JSON parsing
    sig = request.headers.get("X-SocialAPI-Signature")
    if not verify(raw, sig):
        return "Invalid signature", 401
    event = request.get_json()  # parse only after verifying
    queue.push(event)           # dedupe on event["data"]["id"]
    return "", 200
go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"os"
)

var secret = []byte(os.Getenv("SOCAPI_WEBHOOK_SECRET"))

func verify(rawBody []byte, header string) bool {
	mac := hmac.New(sha256.New, secret)
	mac.Write(rawBody)
	expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
	// hmac.Equal is constant time.
	return hmac.Equal([]byte(expected), []byte(header))
}

func handle(w http.ResponseWriter, r *http.Request) {
	raw, err := io.ReadAll(r.Body) // raw bytes, before any decode
	if err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	if !verify(raw, r.Header.Get("X-SocialAPI-Signature")) {
		http.Error(w, "Invalid signature", http.StatusUnauthorized)
		return
	}
	w.WriteHeader(http.StatusOK) // ack first
	// json.Unmarshal(raw, &event) only after verifying.
	// Dedupe on event.Data.ID; the same id can arrive on retry.
}

Three details carry across all three. First, the raw body is read before any JSON layer in every example (express.raw, request.get_data(), io.ReadAll), because verifying a reparsed body is mistake one. Second, the compare is always the standard-library constant-time function, never ==. Third, verification gates parsing: nothing deserializes the payload until the signature has passed. Retries are real on SocialAPI.ai (5 attempts with backoff: immediate, ~30s, ~5m, ~30m, ~3h), so the same verified event id can arrive more than once and your downstream must be idempotent on event.data.id. The full endpoint reference and the registration flow live at the SocialAPI.ai webhooks guide.

Beyond HMAC: what to add by threat model?

HMAC verification proves a request is authentic and intact, but it does not prove the request is fresh, does not encrypt anything, and does not constrain who can reach the endpoint. Whether you need more depends on your threat model. The honest framing is a layered list where each control answers a specific threat, and you adopt the layers that match the threats you actually face rather than all of them by default.

  1. 1.HTTPS, always, non-negotiable. TLS protects the body and signature in transit so a network attacker cannot read or rewrite the payload. SocialAPI.ai rejects plaintext HTTP at registration and the verification ping fails on self-signed certificates, so this layer is enforced rather than optional. It is the floor every other control sits on.
  2. 2.Timestamp and replay window. HMAC says authentic, not recent. If your events trigger side effects, bind a timestamp into what you verify and reject deliveries outside a short window (a few minutes), then deduplicate on the stable interaction id so a captured-and-replayed delivery becomes a no-op. This is the single most overlooked layer and the cheapest one to add.
  3. 3.IP allowlisting. If the producer publishes stable egress ranges, allowlisting them at your edge cuts off the entire class of attackers who cannot even reach the endpoint. Treat it as defense in depth, not a replacement for HMAC: source IPs can be spoofed at the network layer in some environments, and ranges change, so it narrows the surface rather than closing it.
  4. 4.mTLS for high-assurance pipelines. Mutual TLS authenticates the client at the transport layer with a certificate, so the connection itself proves origin before any body is read. It is the right control when a forged event has regulatory or financial consequences and you can coordinate certificate rotation with the producer. For most webhook consumers HMAC plus HTTPS plus a replay window is sufficient; mTLS is the layer you add when the cost of a forged request is severe.

Stack these by consequence, not by checklist completeness. A pipeline that creates a support ticket needs HMAC, HTTPS, and a replay window. A pipeline that triggers a refund or writes to a system of record warrants IP allowlisting and possibly mTLS on top. The discipline is to match the control to the blast radius of a forged or replayed event, the same reasoning that drives the social media API rate limits decisions on the read path. Over-engineering verification on a low-stakes endpoint is wasted effort; under-engineering it on a high-stakes one is a vulnerability. SocialAPI.ai handles the producer side (per-endpoint secrets, HTTPS enforcement, signed deliveries, retry semantics) so the layers above are the only ones you own; the available endpoints and limits are on the webhook plans page.

Frequently asked questions

What is webhook signature verification?
Webhook signature verification is authenticating an inbound HTTP callback by checking a cryptographic proof attached to the request instead of trusting it because it reached the right URL. The producer computes a keyed hash (an HMAC) over the exact request body with a shared secret and sends it in a header; your server recomputes the same hash and compares them in constant time. A match proves the request came from the producer and was not modified in transit. SocialAPI.ai sends this as the X-SocialAPI-Signature header.
Why is HMAC-SHA256 used for webhooks instead of an API key or a plain hash?
An API key in a header proves nothing about the body: an attacker who sees one request can replay or modify it. A plain SHA-256 of the body proves nothing about origin because anyone can hash a body. HMAC-SHA256 combines a secret key with the message, so it simultaneously proves the request came from a party holding the secret and that the body is unmodified. That dual property, origin authenticity plus body integrity from one shared key, is exactly what a public webhook endpoint needs, which is why it is the near-universal choice among webhook producers.
Why does my signature verification fail even though my code looks correct?
Almost always because you are hashing a reparsed body instead of the raw bytes. JSON middleware deserializes and re-serializes the payload, changing key order, whitespace, and unicode escaping, so your recomputed HMAC is over different bytes than the producer signed. Read the raw request body before any JSON parsing (express.raw, request.get_data(), io.ReadAll) and HMAC exactly those bytes. The second most common cause is comparing with the sha256= prefix on one side but not the other.
What is a constant-time comparison and why does it matter for webhooks?
A constant-time comparison takes the same amount of time regardless of where two values first differ, unlike == which short-circuits on the first mismatching byte. That early exit is a measurable timing signal that, over many requests, lets an attacker recover a valid signature one byte at a time. Use crypto.timingSafeEqual in Node, hmac.compare_digest in Python, or hmac.Equal in Go. Equalize the lengths first so the comparison itself does not leak length information or throw.
Does HMAC verification protect against replay attacks?
No. HMAC proves a request is authentic and intact, but a valid signed request stays valid forever, so an attacker who captures one legitimate delivery can replay it. To stop replay, bind a timestamp into what you verify and reject deliveries outside a short window, then deduplicate on a stable interaction id so a replayed delivery becomes a no-op. This is independent of HMAC and is the most commonly skipped layer on otherwise correctly verified endpoints.
How do I verify a SocialAPI.ai webhook signature?
Read the raw request body before any JSON parsing, compute HMAC-SHA256 of those exact bytes using your per-endpoint secret as the key, hex-encode it, prepend sha256=, and compare it to the X-SocialAPI-Signature header with a constant-time function. Reject any request that fails with a 401 and never parse the body on failure. Working Node, Python, and Go handlers are above, and the registration flow and full reference are in the SocialAPI.ai webhooks guide at docs.social-api.ai/guides/webhooks.
Do I still need HTTPS and IP allowlisting if I verify signatures?
HTTPS is non-negotiable regardless: without TLS a network attacker can read the body and signature, and SocialAPI.ai enforces HTTPS at registration. IP allowlisting and mTLS are defense in depth you add by threat model, not replacements for HMAC. Match the layer to the blast radius of a forged event: HMAC plus HTTPS plus a replay window is sufficient for most consumers; allowlisting and mTLS are warranted when a forged request has financial or regulatory consequences.

Webhook signature verification is not a feature you bolt on once and forget. It is a gate every inbound request passes through before your code does anything, and it only works if all four conditions hold at the same time: raw bytes in, constant-time compare, a per-endpoint secret, and a replay window for anything with side effects. SocialAPI.ai owns the producer side of that contract (signed deliveries, per-endpoint secrets, enforced HTTPS, and the 5-attempt retry schedule) so the only code you own is the verification gate shown above. For how this fits the full real-time pipeline see the social listening webhooks guide, for the conceptual model see the unified social inbox API pillar, and for exact endpoints and the registration flow read the reference at docs.social-api.ai.

Get started today

Ready to unify your social interactions?

Free tier available · No credit card required · Ships with MCP server

We use essential cookies for security, and analytics cookies (PostHog) with your consent. Privacy Policy.