Skip to content

Webhook Signature Verification

DodaTech 5 min read

title: "Webhook Signature Verification" description: "Learn how to implement webhook signature verification using HMAC, RSA, and other signing methods to ensure payload authenticity and integrity." weight: 15 date: 2026-06-28 lastmod: 2026-06-28 tags: ["apis", "webhooks"]


Signature verification ensures that webhook payloads come from the claimed provider and have not been tampered with during transit. It is the most important security measure for webhook consumers.

## What You'll Learn

- HMAC-based signing with shared secrets
- Signature header formats
- Timing-safe comparison
- Key rotation strategies
- Consumer-side verification implementation

## Why It Matters

Without signature verification, any attacker who discovers your webhook URL can send fake events. Signatures provide cryptographic proof of the payload's origin and integrity.

## Real-World Use

A payment processor signs every webhook with an HMAC-SHA256 signature using a per-merchant secret. The merchant's server verifies the signature before processing any payment events, preventing fraud from fake webhook calls.

## Flow Chart

```mermaid
sequenceDiagram
    participant P as Provider
    participant C as Consumer
    
    P->>P: Create payload
    P->>P: Sign(payload, secret) = signature
    P->>C: POST payload + X-Signature-256
    C->>C: Read secret for webhook ID
    C->>C: Compute expected = Sign(payload, secret)
    C->>C: Compare signatures
    Note over C: If match: process
    Note over C: If mismatch: reject

Code Examples

Example 1: HMAC-SHA256 Provider Signing

// Provider: signing webhook payloads
const crypto = require('crypto');

function signWebhook(payload, secret) {
  // Normalize payload as string
  const payloadStr = typeof payload === 'string'
    ? payload
    : JSON.stringify(payload);

  // Create HMAC-SHA256 signature
  const signature = crypto
    .createHmac('sha256', secret)
    .update(payloadStr, 'utf8')
    .digest('hex');

  return signature;
}

async function sendSignedWebhook(url, payload, secret) {
  const payloadStr = JSON.stringify(payload);
  const timestamp = Math.floor(Date.now() / 1000);
  
  // Include timestamp in signature to prevent replay attacks
  const signedPayload = `${timestamp}.${payloadStr}`;
  const signature = signWebhook(signedPayload, secret);

  try {
    const response = await axios.post(url, payload, {
      headers: {
        'Content-Type': 'application/json',
        'X-Webhook-Signature': 
          `t=${timestamp},v1=${signature}`,
        'X-Webhook-ID': payload.id,
      },
    });
    return response.status;
  } catch (error) {
    throw new Error(`Webhook delivery failed: ${error.message}`);
  }
}

// Usage
const webhookSecret = process.env.WEBHOOK_SECRET;
const payload = {
  id: 'evt_001',
  type: 'order.created',
  data: { orderId: 'ord_123' },
};

sendSignedWebhook(
  'https://consumer.example.com/webhook',
  payload,
  webhookSecret
);

Expected output: Provider creates HMAC-SHA256 signature including timestamp and sends it in the X-Webhook-Signature header.

Example 2: Consumer Side Verification

// Consumer: verifying webhook signatures
const crypto = require('crypto');

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

  if (!signature) {
    return res.status(401).json({ error: 'Missing signature' });
  }

  // Parse signature header
  const parts = signature.split(',');
  const timestamp = parts.find(p => p.startsWith('t='))?.slice(2);
  const signatureValue = parts.find(p => p.startsWith('v1='))?.slice(3);

  if (!timestamp || !signatureValue) {
    return res.status(401).json({ error: 'Invalid signature format' });
  }

  // Prevent replay attacks (allow 5 minute tolerance)
  const now = Math.floor(Date.now() / 1000);
  if (now - parseInt(timestamp) > 300) {
    return res.status(401).json({ error: 'Signature expired' });
  }

  // Look up secret for this webhook
  const secret = getWebhookSecret(webhookId);

  // Verify signature
  const payload = JSON.stringify(req.body);
  const signedContent = `${timestamp}.${payload}`;
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedContent, 'utf8')
    .digest('hex');

  // Timing-safe comparison
  if (!crypto.timingSafeEqual(
    Buffer.from(signatureValue),
    Buffer.from(expectedSignature)
  )) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Signature verified, process webhook
  processWebhook(req.body);
  res.status(200).json({ status: 'ok' });
});

function getWebhookSecret(webhookId) {
  // Retrieve secret from database
  return database.getWebhookSecret(webhookId);
}

Expected output: Consumer parses signature header, converts to timing-safe comparison, validates timestamp for replay protection, and processes the webhook.

Example 3: Multiple Signature Schemes

// Provider supporting multiple signature schemes
const crypto = require('crypto');

function signPayload(payload, options) {
  const { algorithm, secret, privateKey, keyId } = options;
  const payloadStr = JSON.stringify(payload);
  const timestamp = Math.floor(Date.now() / 1000);
  const signedContent = `${timestamp}.${payloadStr}`;

  let signature;
  switch (algorithm) {
    case 'hmac-sha256':
      signature = crypto
        .createHmac('sha256', secret)
        .update(signedContent)
        .digest('base64');
      break;

    case 'rsa-sha256':
      signature = crypto
        .sign('sha256', Buffer.from(signedContent), privateKey)
        .toString('base64');
      break;

    case 'hmac-sha512':
      signature = crypto
        .createHmac('sha512', secret)
        .update(signedContent)
        .digest('base64');
      break;
  }

  return {
    signature,
    algorithm,
    timestamp,
    keyId,
  };
}

// Headers for different schemes
const hmacHeaders = {
  'X-Signature-256': `t=${timestamp},hmac=${signature}`,
};

const rsaHeaders = {
  'X-Signature-RSA': `t=${timestamp},rsa=${signature},keyid=${keyId}`,
  'X-Signature-Algorithm': 'rsa-sha256',
};

// Consumer verification for RSA
function verifyRSASignature(payload, signature, timestamp, publicKey) {
  const signedContent = `${timestamp}.${JSON.stringify(payload)}`;
  return crypto.verify(
    'sha256',
    Buffer.from(signedContent),
    publicKey,
    Buffer.from(signature, 'base64')
  );
}

Expected output: Provider supports multiple signing algorithms (HMAC, RSA) and consumers verify using the appropriate method.

Common Mistakes

Mistake Explanation
Using string comparison for signatures Use timingSafeEqual or hmac.compare_digest to prevent timing attacks
Not including timestamp in signature Without timestamps, captured signatures can be replayed indefinitely
Accepting unsigned webhooks Even in development, always verify signatures to catch integration issues
Hardcoding secrets in source code Store secrets securely, use environment variables or secret management services
Not handling key rotation Consumers must support multiple active keys during rotation periods

Practice Questions

  1. Why is HMAC preferred for webhook signing?
  2. How does the timestamp prevent replay attacks?
  3. What is a timing-safe comparison and why is it important?
  4. How do you rotate webhook signing secrets?
  5. What should a consumer do when signature verification fails?

Challenge

Implement signature verification for a webhook system that supports both HMAC-SHA256 and RSA-SHA256 signing. Include key rotation support where the provider publishes new keys via an API endpoint and consumers can fetch updated keys.

FAQ

What is the difference between HMAC and RSA signing?

HMAC uses a shared secret (symmetric), RSA uses public/private key pairs (asymmetric). RSA allows the provider to keep the signing key private while consumers only need the public key.

Should I sign the raw body or the parsed JSON?

Sign the raw request body (before parsing) to ensure byte-for-byte consistency. JSON parsing can change whitespace.

How do I handle Unicode characters in signatures?

UTF-8 encode the payload before signing. Both provider and consumer must use the same encoding.

What is a good tolerance window for timestamps?

5 minutes is standard. Longer windows increase replay attack risk, shorter windows cause issues with clock skew.

Can I have multiple signatures in one header?

Yes, use comma-separated values with different versions. This enables gradual key rotation.

How do I test signature verification?

Send a known payload with a known secret, verify the signature matches. Most providers offer test mode with predictable secrets.

Mini Project

Build a webhook signature verification library that supports HMAC-SHA256 and HMAC-SHA512, includes timing-safe comparison, timestamp validation with configurable tolerance, and automatic key rotation. Include comprehensive tests.

What's Next

Learn about webhook retry policies

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro