Skip to content

Webhook Signing and Verification (HMAC)

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Webhook Signing and Verification (HMAC). We cover key concepts, practical examples, and best practices to help you master this topic.

Learn webhook signing and verification: HMAC-SHA256 payload signing, secret management, signature header formats, verification in multiple languages, timing-safe comparison, and replay attack prevention.

What You Learn

You will learn how to sign webhook payloads with HMAC-SHA256, verify signatures in multiple programming languages, manage secrets securely, prevent replay attacks with timestamps, and implement timing-safe comparison.

Why It Matters

Without signature verification, anyone can send fake Webhooks to your endpoint. Attackers can replay captured webhooks or craft malicious payloads. HMAC signing proves the payload came from the provider and has not been tampered with during transit.

Real-World Use

DodaTech's webhook system signs every payload with HMAC-SHA256. The signature verification layer blocks 99.9% of unauthorized requests. In 2025, it prevented 12000 fake webhook attempts targeting partner endpoints.

How HMAC Signing Works

graph LR
    Secret[Shared Secret] --> HMAC[HMAC-SHA256]
    Payload[Raw JSON Body] --> HMAC
    HMAC --> Signature[hex digest]
    Provider[Provider] -->|POST body + Signature header| Consumer[Consumer]
    Consumer --> Verify{Verify}
    Secret --> Verify
    Payload --> Verify
    Verify -->|Match| Accept[Accept webhook]
    Verify -->|Mismatch| Reject[Reject webhook]

The provider and consumer share a secret. The provider computes HMAC of the raw body and includes it as a header. The consumer recomputes HMAC with the same secret and compares.

Provider: Signing a Payload

// Provider-side signing
const crypto = require('crypto');

function signWebhook(payload, secret) {
    // Use the raw JSON string, not the parsed object
    const rawBody = typeof payload === 'string'
        ? payload
        : JSON.stringify(payload);

    const signature = crypto
        .createHmac('sha256', secret)
        .update(rawBody, 'utf8')
        .digest('hex');

    return {
        signature: `sha256=${signature}`,
        rawBody,
    };
}

// Usage
const webhookSecret = 'whsec_abc123def456';
const eventPayload = {
    event: 'payment.succeeded',
    data: { id: 'pi_123', amount: 5000 },
};

const { signature, rawBody } = signWebhook(eventPayload, webhookSecret);

// Headers to send with the POST request
const headers = {
    'Content-Type': 'application/json',
    'X-Webhook-Signature': signature,
};

Expected output: Signature format is sha256=<hex_digest>. The raw JSON body must be used byte-for-byte. Stringifying the same object twice may produce different spacing.

Consumer: Verifying a Signature

// Consumer-side verification (Node.js)
const express = require('express');
const crypto = require('crypto');

const app = express();
const WEBHOOK_SECRET = 'whsec_abc123def456';

// Capture raw body for signature verification
app.use(express.json({
    verify: (req, res, buf) => {
        req.rawBody = buf.toString();
    },
}));

app.post('/webhooks/payment', (req, res) => {
    const signature = req.headers['x-webhook-signature'];
    const timestamp = req.headers['x-webhook-timestamp'];

    // 1. Verify signature
    if (!verifySignature(req.rawBody, signature, WEBHOOK_SECRET)) {
        console.error('Invalid signature');
        return res.status(401).send('Invalid signature');
    }

    // 2. Check timestamp (prevent replay)
    const age = Date.now() - new Date(timestamp).getTime();
    if (age > 300000) { // 5 minutes
        console.error('Webhook too old');
        return res.status(400).send('Stale webhook');
    }

    // 3. Process validated webhook
    res.status(200).send('OK');
    setImmediate(() => processPayment(req.body));
});

function verifySignature(rawBody, signatureHeader, secret) {
    if (!signatureHeader || !rawBody) return false;

    // Extract the actual hex digest from "sha256=abc123..."
    const expectedSig = signatureHeader.startsWith('sha256=')
        ? signatureHeader.slice(7)
        : signatureHeader;

    const computedSig = crypto
        .createHmac('sha256', secret)
        .update(rawBody, 'utf8')
        .digest('hex');

    // Use timing-safe comparison
    return timingSafeEqual(computedSig, expectedSig);
}

function timingSafeEqual(a, b) {
    if (a.length !== b.length) {
        return false;
    }
    return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
}

Expected output: Invalid signatures return 401. Valid signatures with old timestamps return 400. Valid and fresh webhooks return 200 and Process normally.

Verification in Python

import hmac
import hashlib
from flask import Flask, request, jsonify

app = Flask(__name__)
WEBHOOK_SECRET = b'whsec_abc123def456'

@app.route('/webhooks/payment', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Webhook-Signature')
    timestamp = request.headers.get('X-Webhook-Timestamp')
    raw_body = request.get_data(as_text=True)

    if not verify_signature(raw_body, signature, WEBHOOK_SECRET):
        return 'Invalid signature', 401

    # Check timestamp
    # (parse ISO timestamp, check age)

    return 'OK', 200

def verify_signature(raw_body, signature_header, secret):
    if not signature_header or not raw_body:
        return False

    if signature_header.startswith('sha256='):
        expected_sig = signature_header[7:]
    else:
        expected_sig = signature_header

    computed_sig = hmac.new(
        secret,
        raw_body.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(computed_sig, expected_sig)

Expected output: Python uses hmac.compare_digest for timing-safe comparison. Flask's get_data(as_text=True) preserves the raw body exactly as received.

Verification in Go

package main

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

var webhookSecret = []byte("whsec_abc123def456")

func verifySignature(rawBody string, signatureHeader string) bool {
    if signatureHeader == "" || rawBody == "" {
        return false
    }

    expectedSig := strings.TrimPrefix(signatureHeader, "sha256=")

    mac := hmac.New(sha256.New, webhookSecret)
    io.WriteString(mac, rawBody)
    computedSig := hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(computedSig), []byte(expectedSig))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    signature := r.Header.Get("X-Webhook-Signature")

    if !verifySignature(string(body), signature) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}

Expected output: Go uses hmac.Equal for timing-safe comparison. The signature header is extracted and compared against the computed HMAC.

Secret Rotation

// Support multiple secrets for rotation
class WebhookSecretManager {
    constructor() {
        this.secrets = new Map();
        this.currentVersion = 1;
        this.rotationInterval = 90 * 24 * 60 * 60 * 1000; // 90 days
    }

    addSecret(version, secret) {
        this.secrets.set(version, secret);
    }

    getCurrentSecret() {
        return this.secrets.get(this.currentVersion);
    }

    getSecretByVersion(version) {
        return this.secrets.get(version);
    }

    rotateSecret() {
        const newVersion = this.currentVersion + 1;
        const newSecret = generateSecret();
        this.addSecret(newVersion, newSecret);

        // Keep old secret for overlap period
        console.log(`Rotated to secret v${newVersion}`);

        // Schedule old secret removal
        setTimeout(() => {
            this.secrets.delete(this.currentVersion);
            console.log(`Old secret v${this.currentVersion} removed`);
        }, this.rotationInterval);

        this.currentVersion = newVersion;
    }

    verifyWithRotation(rawBody, signatureHeader) {
        // Try all active secrets
        for (const [version, secret] of this.secrets) {
            if (verifySignature(rawBody, signatureHeader, secret)) {
                return { valid: true, version };
            }
        }
        return { valid: false };
    }
}

function generateSecret() {
    return crypto.randomBytes(32).toString('hex');
}

Expected output: During rotation, both old and new secrets are valid. Consumers gradually switch to the new secret. After the overlap period, the old secret is removed.

Common Mistakes

1. Comparing Parsed JSON Instead of Raw Body

JSON.stringify produces different output for the same data due to key ordering and whitespace. Always sign and verify the raw HTTP body bytes.

2. Using String Comparison Instead of Timing-Safe

String comparison (===) leaks timing information. Attackers can guess the signature character by character. Use timing-safe comparison (crypto.timingSafeEqual, hmac.compare_digest, hmac.Equal).

3. Not Checking Timestamp

Without timestamp verification, attackers can replay captured webhooks days later. Always check that the webhook timestamp is within 5 minutes of the current time.

4. Storing Secrets in Code or Config Files

Secrets in code or config files are leaked through git history, screenshots, or CI logs. Use environment variables or a secrets manager (Vault, AWS Secrets Manager).

5. No Secret Rotation

A compromised secret gives attackers unlimited fake webhook capabilities. Rotate secrets every 90 days. Support a rotation window where both old and new secrets are valid.

Practice Questions

1. Why must you use the raw HTTP body for HMAC calculation?

Parsed JSON does not preserve the exact byte sequence sent. Key ordering, whitespace, and number formatting can change. HMAC must be computed on the exact bytes received.

2. What is a timing-safe comparison and why is it important?

Timing-safe comparison takes the same time regardless of how many characters match. Regular string comparison short-circuits on the first mismatch, leaking information about the correct signature through response times.

3. How do you handle webhook replay attacks?

Include a timestamp in the webhook headers. The consumer checks that the timestamp is within 5 minutes. Old webhooks are rejected even with valid signatures.

4. How does secret rotation work in webhook systems?

A new secret is generated and shared with consumers. Both old and new secrets are valid during an overlap period (usually the secret's remaining lifetime). After the overlap, the old secret is revoked.

Challenge

Build a complete webhook security system: HMAC-SHA256 signing on the provider, signature verification on the consumer, timestamp validation, secret rotation with overlap period, and support for multiple signature algorithms (SHA256, SHA512).

FAQ

What HMAC algorithm should I use?

HMAC-SHA256 is the industry standard. It is fast, secure, and supported by every programming language. HMAC-SHA512 is more secure but slower. Avoid MD5 and SHA1.

How do providers communicate the shared secret?

Secrets are shared via the provider dashboard or API during webhook registration. Never send secrets via email or chat. Rotate the secret if it may have been exposed.

Can I use asymmetric signing (RSA/ECDSA) instead of HMAC?

Yes. Some providers use asymmetric signing where the consumer only needs the public key. This eliminates the need to share secrets. Svix and Standard Webhooks support this.

What if the signature header is missing?

Reject the webhook with 401 Unauthorized. Missing signature is either a misconfiguration or an attack attempt. Log the event for investigation.

How do I test signature verification?

Most providers have a test mode or development secrets. Use these in testing. Verify with known-good payloads. Test invalid signatures, missing signatures, and expired timestamps.

Mini Project: Signature Verification Library

Build a library that: signs webhook payloads with HMAC-SHA256, verifies signatures with timing-safe comparison, supports multiple active secrets for rotation, extracts and validates timestamps, and provides middleware for Express, Flask, and Go HTTP servers.

What's Next

Now that you understand webhook security, learn about Retry Policy to handle delivery failures reliably.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro