Don't like ads? Go Ad-Free Today

HMAC How Webhooks Know You’re Not Lying

Published on

Every time GitHub, Stripe, or Shopify fires a webhook at your server, they sign the payload with HMAC. Here's exactly how it works — and how to verify it in your own code.

HMAC: How Webhooks Know You're Not Lying 1
ADVERTISEMENT · REMOVE?

You receive a POST request claiming to be from Stripe. The payload says a payment just succeeded. You process the order, ship the goods — and three days later you discover the request was faked. Ouch.

This is why every serious webhook provider signs their payloads using HMAC. If you understand HMAC, you understand why GitHub, Stripe, Shopify, Twilio, and practically every modern API uses it — and you’ll know exactly how to verify those signatures in your own server code.

What HMAC actually is

HMAC stands for Hash-based Message Authentication Code. It answers one question: “Did the person who sent me this message know the shared secret?”

It works by running a cryptographic hash function — usually SHA-256 — over the combination of a secret key and the message body. The output is a fixed-length string that:

  • Changes completely if even one byte of the message changes
  • Cannot be produced without knowing the secret key
  • Cannot be reversed to reveal the key or the original message

The formula is compact: HMAC(key, message) = H((key ⊕ opad) || H((key ⊕ ipad) || message)). You don’t need to memorise the internals — every language ships a standard library implementation — but it helps to know the intent: the key is mixed into the hash twice, in different ways, to prevent a class of attacks called length-extension attacks.

How a webhook provider uses it

When you register a webhook endpoint, the provider gives you a signing secret — a random string only you and they know. When an event fires:

  1. The provider serialises the event payload to JSON (or a canonical string).
  2. It computes HMAC-SHA256(secret, payload).
  3. It sends the request with the signature in a header — X-Hub-Signature-256 for GitHub, Stripe-Signature for Stripe, and so on.

On your end, you do the same computation over the raw request body and compare. If they match, the payload is authentic. If they don’t, discard it.

Verifying in code

Here’s what verification looks like across the most common languages. The pattern is identical in every one: compute, compare with a constant-time function.

Node.js

const crypto = require('crypto');

function verifyWebhook(rawBody, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)          // rawBody must be a Buffer or string — NOT parsed JSON
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}

Python

import hmac
import hashlib

def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        raw_body,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

PHP

function verifyWebhook(string $rawBody, string $signature, string $secret): bool {
    $expected = hash_hmac('sha256', $rawBody, $secret);
    return hash_equals($expected, $signature);
}

The critical detail: use the raw body

The most common implementation mistake is passing a parsed and re-serialised payload to the HMAC function instead of the original raw bytes. Key ordering, whitespace, and Unicode escaping all affect the hash. The provider signed the exact bytes it sent over the wire — you must hash exactly those same bytes.

In Express (Node.js), that means configuring the body parser to preserve the raw buffer:

app.use('/webhooks', express.raw({ type: 'application/json' }));

In Django, use request.body rather than request.data. In Flask, use request.get_data().

Why not just a plain hash?

A plain SHA-256 of the payload proves nothing — anyone can compute SHA256(payload) without a secret. HMAC’s secret key is what makes it an authentication code, not just a checksum. It answers “who sent this” rather than just “was this corrupted in transit”.

Why not asymmetric signatures (like RSA)?

RSA and ECDSA let the receiver verify a signature without knowing the private key — that’s valuable for public broadcast (like code-signing). For webhooks, there are only two parties who ever need to verify the signature: you and the provider. A shared secret is simpler, faster, and equally secure in that model. Some providers (Svix, Clerk) do offer asymmetric webhook signing for cases where you can’t safely store a secret server-side.

Replay attacks — and how to stop them

A valid HMAC signature proves authenticity but not freshness. An attacker who captures a legitimate signed request can replay it later. Stripe counters this by including a timestamp in the Stripe-Signature header and hashing the timestamp alongside the payload body. On your side, you reject any request where the timestamp is more than five minutes old.

If you’re building your own webhook system, do the same: include a monotonically increasing nonce or a Unix timestamp in the signed message, and reject stale requests server-side.

Timing-safe comparison is not optional

Never compare HMAC signatures with a plain equality check (===, ==). Short-circuit string comparison leaks information about how many leading bytes match — an attacker making thousands of requests can reconstruct the expected signature one byte at a time. Always use a constant-time comparison:

  • Node.js: crypto.timingSafeEqual()
  • Python: hmac.compare_digest()
  • PHP: hash_equals()
  • Go: hmac.Equal()
  • Ruby: ActiveSupport::SecurityUtils.secure_compare()

Putting it together: a production-ready webhook handler

Here’s a complete example using GitHub’s X-Hub-Signature-256 header in Node.js:

const express = require('express');
const crypto = require('crypto');

const app = express();
const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET;

// Keep the body as raw bytes — critical!
app.use('/github/webhook', express.raw({ type: 'application/json' }));

app.post('/github/webhook', (req, res) => {
  const sigHeader = req.headers['x-hub-signature-256'];
  if (!sigHeader) return res.status(401).send('Missing signature');

  const sig = sigHeader.replace('sha256=', '');
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(req.body);
  // Safe to process the event now
  console.log('Event type:', req.headers['x-github-event']);

  res.sendStatus(200);
});

app.listen(3000);

Quick reference: who uses what

ProviderHeaderAlgorithmTimestamp in signature?
GitHubX-Hub-Signature-256HMAC-SHA256No
StripeStripe-SignatureHMAC-SHA256Yes
ShopifyX-Shopify-Hmac-Sha256HMAC-SHA256No
TwilioX-Twilio-SignatureHMAC-SHA1No
SlackX-Slack-SignatureHMAC-SHA256Yes
PaddlePaddle-SignatureHMAC-SHA256Yes
Want To enjoy an ad-free experience? Go Ad-Free Today

Install Our Extensions

Add IO tools to your favorite browser for instant access and faster searching

Add to Chrome Extension Add to Edge Extension Add to Firefox Extension Add to Opera Extension

Scoreboard Has Arrived!

Scoreboard is a fun way to keep track of your games, all data is stored in your browser. More features are coming soon!

ADVERTISEMENT · REMOVE?
ADVERTISEMENT · REMOVE?
ADVERTISEMENT · REMOVE?

News Corner w/ Tech Highlights

Get Involved

Help us continue providing valuable free tools

Buy me a coffee
ADVERTISEMENT · REMOVE?