Home/Blog/How HMAC Webhook Signatures Work: A Complete Guide
Technology

How HMAC Webhook Signatures Work: A Complete Guide

Learn how HMAC signatures secure webhooks

By InventiveHQ Team
How HMAC Webhook Signatures Work: A Complete Guide

Understanding Webhook Security

Webhooks are powerful tools for real-time integrations, but they come with a critical security challenge: how do you know that an incoming HTTP request is actually from the service you expect, and not from a malicious actor trying to inject false data or trigger unauthorized actions?

This is where HMAC (Hash-based Message Authentication Code) signatures come in. Every major webhook provider—Stripe, GitHub, Shopify, Slack, Twilio, and others—uses HMAC signatures to secure their webhooks. Understanding how they work is essential for any developer implementing webhook integrations.

What is HMAC?

HMAC is a cryptographic technique that combines a secret key with a message (in this case, your webhook payload) to produce a unique signature. The key properties that make HMAC ideal for webhook security are:

Authentication: Only someone with the shared secret key can generate valid signatures Integrity: Any change to the payload, even a single character, produces a completely different signature Non-repudiation: The sender cannot deny sending the message if the signature is valid Speed: HMAC operations are fast, adding minimal latency to webhook processing

The HMAC Process

Here's how HMAC signatures work in the webhook context:

1. Webhook Provider Generates Signature

When a provider like Stripe needs to send you a webhook, they:

  • Prepare the JSON payload containing event data
  • Combine the payload with a shared secret key (shown in your dashboard)
  • Run this through a hash algorithm (SHA256, SHA1, etc.)
  • Include the resulting signature in the HTTP headers

2. Your Endpoint Receives the Request

Your webhook endpoint receives:

  • The webhook payload in the request body
  • The signature in a header (e.g., X-Stripe-Signature, X-Hub-Signature-256)
  • Sometimes additional data like timestamps

3. You Verify the Signature

To verify authenticity, you:

  • Extract the signature from the headers
  • Use the same secret key to regenerate the HMAC signature from the payload
  • Compare your generated signature with the received signature
  • If they match exactly, the webhook is authentic and unmodified

Provider-Specific HMAC Implementations

Different webhook providers implement HMAC signatures slightly differently. Let's examine the most common patterns:

Stripe: Timestamp-Based Signatures

Stripe includes a timestamp in the signature to prevent replay attacks. Their signature format is:

X-Stripe-Signature: t=1614556800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Components:

  • t: Unix timestamp when Stripe generated the signature
  • v1: The actual HMAC-SHA256 signature

Verification process:

const crypto = require('crypto');

function verifyStripeSignature(payload, signature, secret) {
  // Parse the header
  const elements = signature.split(',');
  const timestamp = elements.find(e => e.startsWith('t=')).split('=')[1];
  const expectedSig = elements.find(e => e.startsWith('v1=')).split('=')[1];

  // Create the signed payload
  const signedPayload = `${timestamp}.${payload}`;

  // Compute the signature
  const computedSig = crypto
    .createHmac('sha256', secret)
    .update(signedPayload, 'utf8')
    .digest('hex');

  // Compare signatures using constant-time comparison
  return crypto.timingSafeEqual(
    Buffer.from(expectedSig),
    Buffer.from(computedSig)
  );
}

Key insight: Stripe signs the combination of timestamp + payload, not just the payload. This prevents replay attacks because old signatures become invalid after 5 minutes.

GitHub: Simple SHA256 Signatures

GitHub uses a simpler approach with just the HMAC signature:

X-Hub-Signature-256: sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17

Verification process:

function verifyGitHubSignature(payload, signature, secret) {
  // Remove the "sha256=" prefix
  const expectedSig = signature.replace('sha256=', '');

  // Compute the signature
  const computedSig = crypto
    .createHmac('sha256', secret)
    .update(payload, 'utf8')
    .digest('hex');

  // Compare signatures
  return crypto.timingSafeEqual(
    Buffer.from(expectedSig),
    Buffer.from(computedSig)
  );
}

Key insight: GitHub signs the raw payload directly. No timestamp is included, so you should implement your own replay attack protection.

Shopify: Base64 HMAC Signatures

Shopify uses SHA256 but encodes the signature in Base64 instead of hex:

X-Shopify-Hmac-SHA256: XWmrJbrFhVCPdTApD5FJEZqIcGNpBzuqQHJDfqq/EJQ=

Verification process:

function verifyShopifySignature(payload, signature, secret) {
  // Compute the signature (Base64 encoded)
  const computedSig = crypto
    .createHmac('sha256', secret)
    .update(payload, 'utf8')
    .digest('base64');

  // Compare signatures
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(computedSig)
  );
}

Key insight: The encoding format (hex vs Base64) is the main difference. Always check your provider's documentation for the exact encoding format.

Twilio: Legacy SHA1 Signatures

Twilio still uses SHA1 for backward compatibility, though they recommend upgrading to SHA256:

X-Twilio-Signature: RmSGAl1+GtO3eDh7D2rYBvhYvLE=

Verification process:

function verifyTwilioSignature(url, params, signature, authToken) {
  // Twilio signs the full URL + sorted parameters
  const data = url + Object.keys(params)
    .sort()
    .map(key => `${key}${params[key]}`)
    .join('');

  // Compute SHA1 signature (Base64 encoded)
  const computedSig = crypto
    .createHmac('sha1', authToken)
    .update(Buffer.from(data, 'utf-8'))
    .digest('base64');

  return signature === computedSig;
}

Key insight: Twilio signs the full URL including query parameters, not just the POST body. This is unique among major providers.

SHA256 vs SHA1: Understanding the Difference

The choice between SHA256 and SHA1 is more than just a technical detail—it's a security consideration:

SHA256 (Secure Hash Algorithm 256-bit)

Advantages:

  • Produces 256-bit (64 character hex) hashes
  • Considered cryptographically secure for current standards
  • Resistant to collision attacks
  • Used by modern providers (Stripe, GitHub, Shopify, Slack)

Example output:

757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17

SHA1 (Secure Hash Algorithm 1)

Limitations:

  • Produces 160-bit (40 character hex) hashes
  • Known collision vulnerabilities discovered in 2017
  • Being phased out across the industry
  • Still used by legacy systems (older Twilio implementations)

Example output:

b6589fc6ab0dc82cf12099d1c2d40ab994e8410c

Migration recommendation: If you're using SHA1 signatures, plan to migrate to SHA256. While SHA1 is still acceptable for HMAC (because the secret key prevents known attacks), SHA256 is the industry standard.

Protecting Against Replay Attacks

HMAC signatures verify authenticity and integrity, but they don't prevent replay attacks by default—where an attacker intercepts a valid webhook and resends it later.

Timestamp-Based Protection (Stripe's Approach)

Implementation:

function verifyWithTimestamp(payload, signature, secret, tolerance = 300) {
  // Extract timestamp from signature
  const timestamp = extractTimestamp(signature);

  // Check if timestamp is recent (within 5 minutes)
  const currentTime = Math.floor(Date.now() / 1000);
  if (Math.abs(currentTime - timestamp) > tolerance) {
    throw new Error('Webhook timestamp too old or too far in future');
  }

  // Verify signature
  return verifySignature(payload, signature, secret);
}

Benefits:

  • Automatically invalidates old webhooks
  • No database lookup required
  • Prevents replay attacks after tolerance window

Considerations:

  • Requires synchronized clocks (not usually an issue with NTP)
  • May reject valid webhooks if your server's clock is wrong

Nonce-Based Protection

A nonce (number used once) is a unique identifier included in each webhook:

const processedNonces = new Set();

function verifyWithNonce(payload, signature, secret) {
  // Verify signature first
  if (!verifySignature(payload, signature, secret)) {
    return false;
  }

  // Extract nonce from payload
  const data = JSON.parse(payload);
  const nonce = data.nonce || data.id;

  // Check if we've seen this nonce before
  if (processedNonces.has(nonce)) {
    throw new Error('Duplicate webhook detected');
  }

  // Store nonce (in production, use Redis with TTL)
  processedNonces.add(nonce);

  return true;
}

Benefits:

  • Guarantees one-time processing
  • Works regardless of clock synchronization
  • Can persist across server restarts

Considerations:

  • Requires storage (database, Redis, etc.)
  • Need to clean up old nonces to prevent memory growth

Idempotency Keys

Many providers include unique event IDs that you can use for idempotency:

const processedEvents = new Set();

async function processWebhook(payload) {
  const event = JSON.parse(payload);

  // Check if we've already processed this event
  if (processedEvents.has(event.id)) {
    console.log(`Event ${event.id} already processed, skipping`);
    return;
  }

  // Process the webhook
  await handleEvent(event);

  // Mark as processed
  processedEvents.add(event.id);
}

Common Signature Verification Failures

Even with the correct secret, signature verification can fail. Here are the most common causes:

1. Incorrect Algorithm

// ❌ Wrong - Using SHA1 when provider uses SHA256
const wrongSig = crypto.createHmac('sha1', secret).update(payload).digest('hex');

// ✅ Correct - Match the provider's algorithm
const correctSig = crypto.createHmac('sha256', secret).update(payload).digest('hex');

2. Modified Payload

// ❌ Wrong - Parsing and re-stringifying changes formatting
const parsed = JSON.parse(payload);
const modifiedPayload = JSON.stringify(parsed); // Spacing/order may differ
const wrongSig = crypto.createHmac('sha256', secret).update(modifiedPayload).digest('hex');

// ✅ Correct - Use raw payload bytes
const correctSig = crypto.createHmac('sha256', secret).update(payload, 'utf8').digest('hex');

3. Encoding Issues

// ❌ Wrong - Incorrect encoding
const wrongSig = crypto.createHmac('sha256', secret).update(payload).digest('base64');

// ✅ Correct - Use hex for providers that expect hex
const correctSig = crypto.createHmac('sha256', secret).update(payload, 'utf8').digest('hex');

4. Secret Key Issues

// ❌ Wrong - Using signing secret instead of webhook secret
const wrongSecret = 'sk_test_...'; // This is your API key, not webhook secret

// ✅ Correct - Use the actual webhook signing secret
const correctSecret = 'whsec_...'; // From your webhook settings

5. Timing-Safe Comparison Not Used

// ❌ Wrong - Vulnerable to timing attacks
if (expectedSig === computedSig) {
  // String comparison leaks timing information
}

// ✅ Correct - Constant-time comparison
if (crypto.timingSafeEqual(Buffer.from(expectedSig), Buffer.from(computedSig))) {
  // Safe from timing attacks
}

Best Practices for Production

When implementing webhook signature verification in production, follow these best practices:

1. Always Verify Signatures

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const payload = req.body;

  // ALWAYS verify before processing
  if (!verifySignature(payload, signature, process.env.WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Now safe to process
  processWebhook(payload);
  res.status(200).send('OK');
});

2. Use HTTPS Only

// In production, enforce HTTPS
if (process.env.NODE_ENV === 'production' && req.protocol !== 'https') {
  return res.status(403).json({ error: 'HTTPS required' });
}

3. Store Secrets Securely

// ❌ Wrong - Hardcoded secrets
const secret = 'whsec_1234567890';

// ✅ Correct - Environment variables or secret managers
const secret = process.env.WEBHOOK_SECRET;
// or
const secret = await secretsManager.getSecret('webhook-secret');

4. Process Asynchronously

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

  // Verify signature
  if (!verifySignature(payload, signature, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  // Acknowledge immediately
  res.status(200).send('OK');

  // Process asynchronously
  await queue.add('webhook', { payload });
});

5. Log All Webhook Attempts

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

  // Log all attempts (success and failure)
  await logger.info('Webhook received', {
    signature: signature.substring(0, 16) + '...',
    timestamp: Date.now(),
    ip: req.ip,
  });

  if (!verifySignature(payload, signature, process.env.WEBHOOK_SECRET)) {
    await logger.warn('Invalid webhook signature', { ip: req.ip });
    return res.status(401).send('Invalid signature');
  }

  // Continue processing...
});

6. Implement Rate Limiting

const rateLimit = require('express-rate-limit');

const webhookLimiter = rateLimit({
  windowMs: 1 * 60 * 1000, // 1 minute
  max: 100, // Limit each IP to 100 requests per minute
  message: 'Too many webhook requests',
});

app.post('/webhook', webhookLimiter, async (req, res) => {
  // Handle webhook
});

7. Monitor Signature Failures

const metrics = require('./metrics');

function verifySignature(payload, signature, secret) {
  const valid = /* verification logic */;

  // Track metrics
  if (valid) {
    metrics.increment('webhook.signature.valid');
  } else {
    metrics.increment('webhook.signature.invalid');
    // Alert if failure rate spikes
  }

  return valid;
}

Testing Webhook Signatures Locally

During development, you need to test webhook signature verification without triggering real events:

Using the Webhook Payload Generator

The easiest way to test webhook signatures locally is to use a webhook payload generator tool. This allows you to:

  1. Select your webhook provider (Stripe, GitHub, Shopify, etc.)
  2. Choose an event type
  3. Enter your webhook secret
  4. Generate a payload with a valid HMAC signature
  5. Send test requests to your local endpoint

Visit our Webhook Payload Generator to create test webhooks with valid signatures for all major providers.

Using ngrok for Local Testing

To receive real webhooks during development:

# Start your local server
npm run dev

# In another terminal, start ngrok
ngrok http 3000

# Use the ngrok URL in your webhook settings
# Example: https://abc123.ngrok.io/webhook

Writing Unit Tests

const crypto = require('crypto');

function generateTestSignature(payload, secret) {
  return crypto
    .createHmac('sha256', secret)
    .update(payload, 'utf8')
    .digest('hex');
}

describe('Webhook signature verification', () => {
  it('should accept valid signatures', () => {
    const payload = '{"event":"test"}';
    const secret = 'test-secret';
    const signature = generateTestSignature(payload, secret);

    expect(verifySignature(payload, signature, secret)).toBe(true);
  });

  it('should reject invalid signatures', () => {
    const payload = '{"event":"test"}';
    const secret = 'test-secret';
    const wrongSignature = 'invalid-signature';

    expect(verifySignature(payload, wrongSignature, secret)).toBe(false);
  });

  it('should reject modified payloads', () => {
    const payload = '{"event":"test"}';
    const secret = 'test-secret';
    const signature = generateTestSignature(payload, secret);

    const modifiedPayload = '{"event":"modified"}';

    expect(verifySignature(modifiedPayload, signature, secret)).toBe(false);
  });
});

Debugging Signature Verification Issues

When signatures aren't validating, use this systematic debugging approach:

Step 1: Log Everything

function debugSignatureVerification(payload, signature, secret) {
  console.log('=== Signature Verification Debug ===');
  console.log('Payload (first 100 chars):', payload.substring(0, 100));
  console.log('Payload length:', payload.length);
  console.log('Received signature:', signature);
  console.log('Secret (first 8 chars):', secret.substring(0, 8) + '...');

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

  console.log('Computed signature:', computed);
  console.log('Signatures match:', signature === computed);
  console.log('====================================');

  return signature === computed;
}

Step 2: Verify Raw Payload

// Ensure you're using raw body, not parsed
app.post('/webhook',
  express.raw({ type: 'application/json' }), // Raw buffer
  (req, res) => {
    const payload = req.body.toString('utf8'); // Convert to string
    // Now verify signature with payload
  }
);

Step 3: Check Algorithm and Encoding

// Try different combinations
const algorithms = ['sha256', 'sha1', 'sha512'];
const encodings = ['hex', 'base64'];

algorithms.forEach(algo => {
  encodings.forEach(enc => {
    const sig = crypto
      .createHmac(algo, secret)
      .update(payload, 'utf8')
      .digest(enc);

    console.log(`${algo} + ${enc}: ${sig.substring(0, 32)}...`);
    if (sig === receivedSignature) {
      console.log(`✓ MATCH: ${algo} + ${enc}`);
    }
  });
});

Step 4: Verify with Provider's Test Mode

Most providers offer test mode webhooks:

// Stripe test webhook secret (starts with whsec_test_)
const testSecret = 'whsec_test_...';

// GitHub test delivery button in webhook settings

// Shopify test notification in webhook settings

Conclusion

HMAC signatures are the cornerstone of webhook security, providing authentication and integrity verification that protects your application from malicious requests. By understanding how HMAC works, implementing provider-specific verification correctly, and following production best practices, you can build secure webhook integrations with confidence.

Key takeaways:

  • Always verify HMAC signatures before processing webhooks
  • Use the correct algorithm (SHA256 is preferred over SHA1)
  • Sign the raw payload bytes, never a modified or parsed version
  • Implement replay attack protection with timestamps or nonces
  • Use constant-time comparison to prevent timing attacks
  • Process webhooks asynchronously and return 2xx immediately
  • Store secrets securely in environment variables or secret managers
  • Test thoroughly with both valid and invalid signatures

Need help testing your webhook signatures? Try our Webhook Payload Generator to create test webhooks with valid HMAC signatures for Stripe, GitHub, Shopify, and other providers.

Let's turn this knowledge into action

Get a free 30-minute consultation with our experts. We'll help you apply these insights to your specific situation.

Webhook Security Implementation Workflow

Webhook Security Implementation Workflow

Master the complete webhook security implementation workflow used by backend engineers and API developers. This comprehensive guide covers HMAC signature validation, replay attack prevention, IP allowlisting, payload sanitization, and error handling aligned to OWASP API Security Top 10 2023.

Stripe Webhooks: Complete Guide with Payload Examples [2025]

Stripe Webhooks: Complete Guide with Payload Examples [2025]

Complete guide to Stripe webhooks with setup instructions, payload examples, signature verification, and implementation code. Learn how to integrate Stripe webhooks into your application with step-by-step tutorials for payment processing and subscription management.

GitHub Webhooks: Complete Guide with Payload Examples [2025]

GitHub Webhooks: Complete Guide with Payload Examples [2025]

Complete guide to GitHub webhooks with setup instructions, payload examples, HMAC-SHA256 signature verification, and implementation code. Learn how to integrate GitHub webhooks for CI/CD automation with step-by-step tutorials.

Shopify Webhooks: Complete Guide with Payload Examples [2025]

Shopify Webhooks: Complete Guide with Payload Examples [2025]

Complete guide to Shopify webhooks with setup instructions, payload examples, HMAC-SHA256 signature verification, and implementation code. Learn how to integrate Shopify webhooks into your e-commerce application with step-by-step tutorials for orders, products, customers, and inventory events.

Debugging Webhooks: Troubleshooting Guide and Best Practices

Debugging Webhooks: Troubleshooting Guide and Best Practices

Master webhook debugging with this comprehensive troubleshooting guide. Learn how to diagnose and fix common webhook issues including signature verification failures, timeouts, missing webhooks, duplicate processing, and SSL errors with proven debugging workflows and best practices.

Published Applications vs Application Virtualization | Complete Guide 2025

Published Applications vs Application Virtualization | Complete Guide 2025

Learn the key differences between published applications and application virtualization.