What is a webhook signature?
A webhook signature is a cryptographic hash in a request header that proves the webhook came from the claimed source. The mechanism is HMAC-SHA256: the source has a secret string (issued in their dashboard), computes `HMAC_SHA256(secret, raw_body)` — sometimes with a timestamp prepended — and puts the digest in a header (`Stripe-Signature`, `X-Hub-Signature-256`, `X-Shopify-Hmac-Sha256`). Your handler computes the same HMAC over the same bytes with the same secret and compares; if they match, the request is authentic. Always verify against the raw body (never reserialized JSON) and use a constant-time comparison to prevent timing attacks.
A webhook signature is a short string of bytes the source attaches to each delivery in a header — Stripe-Signature, X-Hub-Signature-256, X-Shopify-Hmac-Sha256 — that lets your handler verify the request actually came from the claimed sender and wasn't tampered with in transit.
The mechanism is HMAC, almost always with SHA-256. The source has a secret string (issued when you create the webhook endpoint in their dashboard). When the source sends a webhook, it computes HMAC_SHA256(secret, raw_body) — sometimes with a timestamp prepended — and puts the result in a header. Your handler computes the same HMAC over the same bytes with the same secret and compares. If they match, the request is authentic; if they don't, reject with HTTP 401.
What signatures defend against, in order of severity. Forgery: anyone on the internet can POST JSON to your endpoint claiming to be Stripe. Signatures stop this — without the secret, an attacker can't produce a valid HMAC. Tampering: a man-in-the-middle that mutated the payload in transit (rare with HTTPS, but signatures defend against it anyway). Replay: an attacker captures a valid signed request and re-sends it later. The defense here is a timestamp inside the signed material plus a tolerance window — see timestamp-tolerance.
Three implementation rules that catch teams. Always verify against the raw body, not parsed JSON. Reserialization (JSON.stringify(JSON.parse(body))) reorders keys and changes whitespace, which changes the HMAC. Use a constant-time comparison when checking signatures (crypto.timingSafeEqual in Node, hmac.compare_digest in Python). String equality leaks timing information that lets an attacker brute-force the signature byte by byte. Never log the signature header alongside the body — the combination is enough to verify offline against your secret if the secret leaks separately.
Each provider has its own signature scheme. Stripe's Stripe-Signature is comma-separated key/value pairs (t=1234,v1=hex); GitHub puts the hex digest after a sha256= prefix; Shopify base64-encodes; Twilio signs URL+form-params. The math is the same; the encoding is different. Read the provider's exact spec — generic "verify HMAC-SHA256" advice is not enough.
Example
import crypto from 'node:crypto'
function verify(rawBody, header, secret) {
// header looks like: t=1697045123,v1=abc123hex...
const parts = Object.fromEntries(
header.split(',').map(p => p.split('='))
)
const signedPayload = `${parts.t}.${rawBody}`
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(parts.v1, 'hex')
)
}import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split('=') for p in header.split(','))
signed_payload = f"{parts['t']}.{raw_body.decode()}".encode()
expected = hmac.new(
secret.encode(), signed_payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts['v1'])See Webhook Signature in real traffic
WebhookWhisper captures every webhook with full headers, body, signature, and timing — so concepts like webhook signature stop being abstract and become something you can inspect.
Start Free