Home/Blog/Webhook Signature Verification: Complete Security Guide
Security

Webhook Signature Verification: Complete Security Guide

Master webhook signature verification across HMAC-SHA256, HMAC-SHA1, RSA-SHA256, and ECDSA algorithms. Learn implementation patterns, security best practices, and avoid common mistakes with production-ready code examples.

By InventiveHQ Team

Webhook signature verification is the critical security mechanism that protects your application from forged requests, man-in-the-middle attacks, and replay attacks. Without proper signature verification, attackers can send malicious payloads to your webhook endpoints, potentially triggering unauthorized actions, data breaches, or financial fraud.

This comprehensive guide covers webhook signature verification across all major algorithms—HMAC-SHA256, HMAC-SHA1, RSA-SHA256, and ECDSA—with production-ready implementation patterns and security best practices.

Why Webhook Signature Verification Matters

Webhooks are server-to-server HTTP callbacks sent by third-party services to notify your application about events. Unlike traditional API requests where your application initiates the connection and controls authentication, webhooks are incoming requests from external services. This reversal creates unique security challenges.

Attack Scenarios Without Signature Verification:

  1. Forged Webhooks: Attackers send fake payment confirmation webhooks to your e-commerce platform, granting access to premium features without payment
  2. Replay Attacks: Captured legitimate webhooks are resent repeatedly to trigger duplicate actions (e.g., multiple refunds for a single transaction)
  3. Parameter Tampering: Webhook payloads are intercepted and modified in transit to change amounts, user IDs, or event types
  4. Denial of Service: Malicious actors flood your webhook endpoint with invalid requests, overwhelming your processing systems

Signature verification cryptographically proves that:

  • The webhook was sent by the legitimate service provider
  • The payload hasn't been modified during transmission
  • The request is recent and not a replay of an old webhook

How Signature Verification Works

Webhook signature verification uses cryptographic signing to establish authenticity. The process follows this pattern:

Sender Side (Webhook Provider):

  1. Provider concatenates webhook payload with metadata (often timestamp, webhook ID)
  2. Provider generates a signature by hashing this data with a signing key
  3. Provider sends the webhook with the signature in an HTTP header
  4. Receiver retrieves the signature from the header

Receiver Side (Your Application):

  1. Extract the signature from the webhook request header
  2. Retrieve the raw request body exactly as received (critical—no parsing or modifications)
  3. Reconstruct the signed data using the same format (payload + metadata)
  4. Generate your own signature using the same algorithm and your copy of the signing key
  5. Compare your computed signature with the received signature using timing-safe comparison
  6. Validate additional constraints (timestamp freshness, webhook ID uniqueness)
  7. Accept the webhook only if all validations pass

The security relies on the signing key (shared secret or private key) being known only to the webhook provider and your application. Without this key, attackers cannot generate valid signatures.

Common Signature Algorithms

Different webhook providers use different cryptographic algorithms based on their security requirements and infrastructure.

HMAC-SHA256 (Most Common)

Used by: Stripe, GitHub, Shopify, Discord, Slack, Square

Algorithm: Hash-based Message Authentication Code using SHA-256 hash function

Key Type: Symmetric (shared secret between provider and receiver)

Why Popular: Fast computation, strong security (256-bit output), simple key management, universal language support

Signature Format: Typically hex-encoded or base64-encoded hash

  • GitHub: sha256={hex_signature}
  • Stripe: t={timestamp},v1={hex_signature}
  • Shopify: Base64-encoded HMAC-SHA256

Node.js Example:

const crypto = require('crypto');

function verifyHmacSha256(payload, signature, secret) {
  const hmac = crypto.createHmac('sha256', secret);
  hmac.update(payload);
  const computed = hmac.digest('hex');

  // Timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(computed)
  );
}

// GitHub webhook verification
const githubSignature = req.headers['x-hub-signature-256'];
const providedSignature = githubSignature.replace('sha256=', '');
const rawBody = req.rawBody; // Must preserve raw body
const secret = process.env.GITHUB_WEBHOOK_SECRET;

if (verifyHmacSha256(rawBody, providedSignature, secret)) {
  // Valid webhook
} else {
  // Invalid signature - reject
}

HMAC-SHA1 (Legacy)

Used by: Twilio, older PayPal implementations

Algorithm: HMAC using SHA-1 hash function

Security Note: SHA-1 is considered cryptographically weak but still acceptable for HMAC usage in webhook contexts where collision attacks are not relevant

Key Type: Symmetric shared secret

When to Use: Only when required by provider; prefer SHA-256 for new implementations

Python Example:

import hmac
import hashlib

def verify_hmac_sha1(payload, signature, secret):
    computed = hmac.new(
        secret.encode('utf-8'),
        payload.encode('utf-8'),
        hashlib.sha1
    ).hexdigest()

    # Timing-safe comparison
    return hmac.compare_digest(signature, computed)

# Twilio webhook verification
twilio_signature = request.headers.get('X-Twilio-Signature')
raw_body = request.get_data(as_text=True)
auth_token = os.environ['TWILIO_AUTH_TOKEN']

if verify_hmac_sha1(raw_body, twilio_signature, auth_token):
    # Valid webhook
else:
    # Invalid signature - reject

RSA-SHA256 (Asymmetric)

Used by: PayPal (newer implementations), some enterprise webhook providers

Algorithm: RSA signature using SHA-256 hash function

Key Type: Asymmetric (provider signs with private key, you verify with public key)

Advantages:

  • No shared secret to protect
  • Public key can be distributed freely
  • Better for scenarios where secret sharing is problematic

Disadvantages:

  • Slower computation than HMAC
  • More complex key management (certificate handling)
  • Requires public key distribution mechanism

Node.js Example:

const crypto = require('crypto');

function verifyRsaSha256(payload, signature, publicKey) {
  const verifier = crypto.createVerify('RSA-SHA256');
  verifier.update(payload);

  return verifier.verify(
    publicKey,
    signature,
    'base64'
  );
}

// PayPal webhook verification
const paypalSignature = req.headers['paypal-transmission-sig'];
const rawBody = req.rawBody;
const publicKey = `-----BEGIN PUBLIC KEY-----
${process.env.PAYPAL_PUBLIC_KEY}
-----END PUBLIC KEY-----`;

if (verifyRsaSha256(rawBody, paypalSignature, publicKey)) {
  // Valid webhook
} else {
  // Invalid signature - reject
}

ECDSA (Elliptic Curve)

Used by: SendGrid, some blockchain/cryptocurrency webhooks

Algorithm: Elliptic Curve Digital Signature Algorithm

Key Type: Asymmetric (public/private key pair)

Advantages:

  • Shorter keys than RSA with equivalent security
  • Faster signature generation than RSA
  • Lower computational overhead

Security: Provides 256-bit security with much smaller keys than RSA-2048

PHP Example:

<?php
function verifyEcdsa($payload, $signature, $publicKey) {
    $signature_decoded = base64_decode($signature);

    $verified = openssl_verify(
        $payload,
        $signature_decoded,
        $publicKey,
        OPENSSL_ALGO_SHA256
    );

    return $verified === 1;
}

// SendGrid webhook verification
$sendgridSignature = $_SERVER['HTTP_X_TWILIO_EMAIL_EVENT_WEBHOOK_SIGNATURE'];
$rawBody = file_get_contents('php://input');
$publicKey = getenv('SENDGRID_PUBLIC_KEY');

if (verifyEcdsa($rawBody, $sendgridSignature, $publicKey)) {
    // Valid webhook
} else {
    // Invalid signature - reject
}
?>

Implementation Patterns

Regardless of algorithm, all robust webhook signature verification implementations share common patterns.

Pattern 1: Raw Body Preservation

Problem: JSON parsers, body parsers, and middleware modify request bodies, invalidating signatures.

Solution: Preserve raw body bytes before any processing.

Express.js (Node.js):

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

// Custom middleware to preserve raw body
app.use('/webhooks', express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString('utf8');
  }
}));

app.post('/webhooks/stripe', (req, res) => {
  const signature = req.headers['stripe-signature'];
  const rawBody = req.rawBody; // Use raw body for verification
  const parsedBody = req.body; // Use parsed body for processing
});

Flask (Python):

from flask import Flask, request

app = Flask(__name__)

@app.route('/webhooks/github', methods=['POST'])
def github_webhook():
    raw_body = request.get_data()  # Get raw bytes
    signature = request.headers.get('X-Hub-Signature-256')

    # Verify signature using raw_body
    if verify_signature(raw_body, signature):
        parsed_data = request.get_json()  # Parse after verification

Pattern 2: Timing-Safe Comparison

Problem: Standard string comparison (=== or ==) leaks timing information that can enable timing attacks.

Solution: Use constant-time comparison functions.

Node.js:

const crypto = require('crypto');

// WRONG - vulnerable to timing attacks
if (computedSignature === providedSignature) {
  // Accept webhook
}

// RIGHT - constant-time comparison
if (crypto.timingSafeEqual(
  Buffer.from(computedSignature),
  Buffer.from(providedSignature)
)) {
  // Accept webhook
}

Python:

import hmac

# WRONG
if computed_signature == provided_signature:
    # Accept webhook

# RIGHT
if hmac.compare_digest(computed_signature, provided_signature):
    # Accept webhook

Pattern 3: Timestamp Validation

Problem: Replay attacks reuse old valid webhooks.

Solution: Include timestamp in signature and reject old requests.

Complete Stripe Verification Example:

const crypto = require('crypto');

function verifyStripeSignature(payload, header, secret, tolerance = 300) {
  // Parse signature header: "t=timestamp,v1=signature"
  const items = header.split(',');
  const timestamp = items.find(item => item.startsWith('t='))?.split('=')[1];
  const signature = items.find(item => item.startsWith('v1='))?.split('=')[1];

  if (!timestamp || !signature) {
    throw new Error('Invalid signature header format');
  }

  // Check timestamp freshness (prevent replay attacks)
  const currentTime = Math.floor(Date.now() / 1000);
  if (currentTime - parseInt(timestamp) > tolerance) {
    throw new Error('Webhook timestamp too old');
  }

  // Compute expected signature
  const signedPayload = `${timestamp}.${payload}`;
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Timing-safe comparison
  if (!crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  )) {
    throw new Error('Signature verification failed');
  }

  return true;
}

app.post('/webhooks/stripe', (req, res) => {
  const signature = req.headers['stripe-signature'];
  const secret = process.env.STRIPE_WEBHOOK_SECRET;

  try {
    verifyStripeSignature(req.rawBody, signature, secret);
    // Process webhook
    res.status(200).send({ received: true });
  } catch (error) {
    console.error('Webhook verification failed:', error.message);
    res.status(400).send({ error: error.message });
  }
});

Security Best Practices

1. Always Verify Before Processing

Never process webhook data before signature verification. Verification must be the first operation:

app.post('/webhooks', async (req, res) => {
  // STEP 1: Verify signature
  if (!verifySignature(req.rawBody, req.headers['signature'])) {
    return res.status(401).send('Unauthorized');
  }

  // STEP 2: Parse and validate data
  const data = JSON.parse(req.rawBody);

  // STEP 3: Process webhook
  await processWebhook(data);

  res.status(200).send('OK');
});

2. Implement Timestamp Validation

Reject webhooks older than 5-10 minutes to prevent replay attacks. Use the Webhook Payload Generator to test with various timestamps.

3. Use HTTPS Only

Webhook endpoints must use HTTPS. HTTP exposes payloads and signatures to eavesdropping and man-in-the-middle attacks.

4. Rotate Secrets Periodically

Implement secret rotation every 90 days. During rotation:

  • Add new secret to verification logic
  • Accept both old and new secrets temporarily
  • Remove old secret after grace period

5. Store Secrets Securely

Never hardcode webhook secrets in code:

// WRONG
const secret = 'whsec_abc123';

// RIGHT
const secret = process.env.WEBHOOK_SECRET;

Use environment variables, secret managers (AWS Secrets Manager, HashiCorp Vault), or configuration services.

6. Log Verification Failures

Monitor failed verification attempts for potential attacks:

if (!verifySignature(body, signature)) {
  logger.warn('Webhook signature verification failed', {
    ip: req.ip,
    timestamp: Date.now(),
    endpoint: req.path
  });
  return res.status(401).send('Unauthorized');
}

7. Implement Rate Limiting

Protect webhook endpoints from abuse with rate limiting:

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

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

app.post('/webhooks', webhookLimiter, handleWebhook);

8. Validate Webhook IDs

Prevent duplicate processing by tracking webhook IDs:

const processedWebhooks = new Set();

async function handleWebhook(webhookId, data) {
  if (processedWebhooks.has(webhookId)) {
    console.log('Duplicate webhook ignored:', webhookId);
    return;
  }

  processedWebhooks.add(webhookId);
  await processWebhookData(data);
}

For production, use a database or cache (Redis) instead of in-memory Set.

9. Return 200 Before Processing

Acknowledge receipt immediately to prevent provider retries, then process asynchronously:

app.post('/webhooks', async (req, res) => {
  if (!verifySignature(req.rawBody, req.headers['signature'])) {
    return res.status(401).send('Unauthorized');
  }

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

  // Process asynchronously
  processWebhookAsync(req.body)
    .catch(error => logger.error('Webhook processing failed', error));
});

10. Test Verification Logic Thoroughly

Use tools like the Webhook Payload Generator to test:

  • Valid signatures are accepted
  • Invalid signatures are rejected
  • Modified payloads fail verification
  • Expired timestamps are rejected
  • Edge cases (empty payloads, special characters) are handled

Common Mistakes

Mistake 1: Using Parsed Body for Verification

// WRONG - body has been parsed and modified
app.use(express.json());
app.post('/webhooks', (req, res) => {
  const body = JSON.stringify(req.body);
  verifySignature(body, signature); // Will fail
});

// RIGHT - preserve raw body
app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString();
  }
}));
app.post('/webhooks', (req, res) => {
  verifySignature(req.rawBody, signature); // Works
});

Mistake 2: Non-Constant-Time Comparison

// WRONG - vulnerable to timing attacks
if (computed === provided) {
  // Accept
}

// RIGHT - constant-time comparison
if (crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(provided))) {
  // Accept
}

Mistake 3: Ignoring Timestamp Validation

// WRONG - accepts replayed webhooks
function verify(payload, signature) {
  return computeHmac(payload) === signature;
}

// RIGHT - validates timestamp
function verify(payload, signature, timestamp) {
  const age = Date.now() / 1000 - timestamp;
  if (age > 300) throw new Error('Timestamp too old');

  return computeHmac(`${timestamp}.${payload}`) === signature;
}

Mistake 4: Processing Before Verification

// WRONG - processes unverified data
app.post('/webhooks', async (req, res) => {
  await processPayment(req.body);
  verifySignature(req.body, signature);
});

// RIGHT - verifies first
app.post('/webhooks', async (req, res) => {
  if (!verifySignature(req.rawBody, signature)) {
    return res.status(401).send('Unauthorized');
  }
  await processPayment(req.body);
});

Mistake 5: Exposing Verification Errors

// WRONG - leaks information to attackers
if (!verifySignature(body, signature)) {
  return res.status(401).json({
    error: 'Expected signature: abc123, got: xyz789'
  });
}

// RIGHT - generic error message
if (!verifySignature(body, signature)) {
  return res.status(401).send('Unauthorized');
}

Testing Webhook Verification

Local Testing Setup

1. Use Webhook Testing Tools

Use our Webhook Payload Generator to generate properly signed test payloads for all major providers.

2. Create Test Utilities

// test-utils.js
const crypto = require('crypto');

function generateTestWebhook(payload, secret) {
  const timestamp = Math.floor(Date.now() / 1000);
  const signedPayload = `${timestamp}.${payload}`;
  const signature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  return {
    body: payload,
    headers: {
      'stripe-signature': `t=${timestamp},v1=${signature}`
    }
  };
}

module.exports = { generateTestWebhook };

3. Test Invalid Signatures

describe('Webhook Signature Verification', () => {
  test('accepts valid signature', () => {
    const webhook = generateTestWebhook('{"event":"test"}', SECRET);
    expect(verifySignature(webhook.body, webhook.headers)).toBe(true);
  });

  test('rejects invalid signature', () => {
    const webhook = generateTestWebhook('{"event":"test"}', 'wrong-secret');
    expect(verifySignature(webhook.body, webhook.headers)).toBe(false);
  });

  test('rejects modified payload', () => {
    const webhook = generateTestWebhook('{"event":"test"}', SECRET);
    webhook.body = '{"event":"modified"}';
    expect(verifySignature(webhook.body, webhook.headers)).toBe(false);
  });

  test('rejects expired timestamp', () => {
    const oldTimestamp = Math.floor(Date.now() / 1000) - 600; // 10 minutes ago
    const signature = generateSignature('{"event":"test"}', oldTimestamp);
    expect(() => verifySignature(payload, signature)).toThrow('Timestamp too old');
  });
});

Testing in Staging

Use provider test modes:

  • Stripe: Test mode webhooks with whsec_test_ secrets
  • GitHub: Configure webhooks on test repositories
  • Shopify: Development stores with test webhooks

Monitor logs to verify:

  • Signatures are validated correctly
  • Timestamp checks work as expected
  • Invalid webhooks are rejected
  • Valid webhooks are processed

Webhook Provider Comparison

ProviderAlgorithmHeader NameFormatTimestamp Validation
StripeHMAC-SHA256Stripe-Signaturet={timestamp},v1={signature}Yes (required)
GitHubHMAC-SHA256X-Hub-Signature-256sha256={signature}Optional
ShopifyHMAC-SHA256X-Shopify-Hmac-SHA256Base64 encodedNo
PayPalRSA-SHA256Paypal-Transmission-SigBase64 encodedYes
TwilioHMAC-SHA1X-Twilio-SignatureBase64 encodedNo
SquareHMAC-SHA256X-Square-SignatureBase64 encodedNo
DiscordHMAC-SHA256X-Signature-Ed25519Hex encodedYes
SlackHMAC-SHA256X-Slack-Signaturev0={signature}Yes (required)
SendGridECDSAX-Twilio-Email-Event-Webhook-SignatureBase64 encodedYes

For provider-specific implementation guides, see our GitHub Webhook Signature Verification, Stripe Webhook Signature Verification, and Shopify Webhook Signature Verification guides.

Conclusion

Webhook signature verification is non-negotiable for production applications. Without it, your webhook endpoints are vulnerable to forgery, tampering, and replay attacks that can compromise data integrity and security.

Key Takeaways:

  1. Always verify signatures before processing webhook data
  2. Preserve raw request bodies - signature verification requires exact bytes
  3. Use timing-safe comparison functions to prevent timing attacks
  4. Validate timestamps to prevent replay attacks (5-minute tolerance)
  5. Choose the right algorithm - HMAC-SHA256 for most use cases, RSA/ECDSA when symmetric secrets aren't viable
  6. Test thoroughly - use tools like our Webhook Payload Generator to validate your implementation
  7. Follow provider documentation - each provider has specific signature formats and requirements
  8. Monitor failures - log verification failures to detect potential attacks
  9. Rotate secrets periodically - implement 90-day rotation with grace periods
  10. Use HTTPS exclusively - never expose webhook endpoints over HTTP

For more webhook security guidance, read our comprehensive Webhook Security Guide, explore provider-specific verification guides, or use our Webhook Payload Generator to test your implementations.

Implement robust signature verification today to protect your webhook endpoints from security threats and ensure the integrity of your event-driven architecture.

Need Expert IT & Security Guidance?

Our team is ready to help protect and optimize your business technology infrastructure.