Webhook Signature Verification — HMAC SHA-256 Guide

Signature verification is the most important security measure for any webhook endpoint. Without it, anyone can send forged payloads to your server. This guide covers how HMAC-SHA256 works, with code examples and provider-specific details.

Why Signature Verification Matters

Your webhook URL is a publicly accessible HTTP endpoint. If an attacker discovers it, they can send fake payloads — triggering false payment confirmations, creating bogus accounts, or corrupting data. Signature verification proves that:

How HMAC-SHA256 Works

The provider and your server share a secret key. On each webhook delivery, the provider computes HMAC-SHA256(secret, request_body) and sends the result in a header. Your server recomputes the same hash and compares the two values.

Go

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

func verifySignature(r *http.Request, secret string) bool {
    body, _ := io.ReadAll(r.Body)
    sig := r.Header.Get("X-Signature-256")

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))

    return subtle.ConstantTimeCompare(
        []byte(sig), []byte("sha256="+expected),
    ) == 1
}

Node.js

Node.js
const crypto = require('crypto');

function verifySignature(body, secret, sigHeader) {
  const expected = 'sha256=' +
    crypto.createHmac('sha256', secret)
      .update(body, 'utf8')
      .digest('hex');

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

Python

Python
import hmac, hashlib

def verify_signature(body: bytes, secret: str, sig_header: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(sig_header, expected)

Provider-Specific Guides

Stripe

Header: Stripe-Signature. Format: t=timestamp,v1=signature. Stripe signs timestamp.body (concatenated with a dot). Use the official Stripe SDK's constructEvent() method for the safest implementation. Secret starts with whsec_.

GitHub

Header: X-Hub-Signature-256. Format: sha256=hex_digest. Standard HMAC-SHA256 over the raw request body. The secret is configured per-webhook in repository or organization settings.

Shopify

Header: X-Shopify-Hmac-Sha256. Format: Base64-encoded HMAC-SHA256 (not hex). This is a common source of bugs — make sure you Base64-encode your computed hash before comparing, not hex-encode it.

Common Mistakes

Do not use == for comparison. Naive string equality is vulnerable to timing attacks. Always use constant-time comparison: subtle.ConstantTimeCompare (Go), crypto.timingSafeEqual (Node.js), or hmac.compare_digest (Python).

Testing Signatures

Use the SDNBros Signature Generator to compute HMAC-SHA256 signatures for test payloads. Paste in your secret and payload body to generate the correct header value, then compare it against what your code produces.

Verify Signatures Automatically

SDNBros validates webhook signatures on every delivery and alerts you to verification failures in real-time.

Start Free →