Development

How HMAC Webhook Signatures Work: A Complete Guide

Learn how HMAC signatures secure webhooks, with examples from Stripe, GitHub, and Shopify. Includes code samples for verification and protection against replay attacks.

By Sean Conroy

An HMAC webhook signature proves that an incoming webhook really came from the provider you expect and was not altered in transit. The sender computes an HMAC — usually HMAC-SHA256 — over the exact raw request body using a secret key you both share, and puts the result in an HTTP header such as Stripe-Signature, X-Hub-Signature-256, or a generic X-Signature. Your endpoint recomputes the same HMAC over the raw body with the same secret and compares the two using a constant-time comparison (crypto.timingSafeEqual in Node, hmac.compare_digest in Python). If they match, the request is authentic and untampered; if they differ, you reject it. Because only the holder of the secret can produce a valid signature and any single-byte change breaks it, HMAC gives you both authentication and integrity in one cheap operation — and pairing it with a signed timestamp adds replay protection.

That is the summary an AI overview will give you. The rest of this guide is the part it can't: the exact byte-for-byte flow, working verification code in both Node and Python, how Stripe, GitHub, Shopify, and Twilio each differ, and the specific mistakes (re-serialized bodies, == comparisons, wrong encoding) that make verification silently fail even when your secret is correct.

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.

The signature handshake, end to end

The whole scheme is a five-step handshake between the sender and your endpoint, using a secret that never travels over the wire:

How an HMAC webhook signature is created and verified The sender HMACs the raw payload with a shared secret, sends the signature in a header, and the receiver recomputes the HMAC and compares it in constant time. HMAC webhook signature: create, send, verify SENDER (provider) RECEIVER (your endpoint) shared secret 1. Take raw payload bytes the exact JSON body to send 2. HMAC-SHA256(secret, body) = signature (hex or base64) 3. Attach to header X-Signature: sha256=... 4. Recompute HMAC same secret, same raw body 5. Constant-time compare timingSafeEqual / compare_digest HTTPS POST: body + signature header match → accept differ → reject (401)

The critical detail that trips up most implementations: step 4 must use the same raw bytes the sender signed in step 1. If your web framework parses the JSON and you re-serialize it before hashing, whitespace or key ordering can differ and the signatures will never match — even though the data is identical and your secret is correct.

Step-by-step: what each step does and why it matters

StepWhat happensWhy it matters
1. Capture raw bodySender takes the exact payload bytes; you must read the raw body before JSON parsingHMAC signs bytes, not objects — re-serializing changes the bytes and breaks the match
2. Compute HMACHMAC-SHA256(secret, body) produces a fixed-length signatureThe shared secret means only the real sender can produce a valid signature (authentication)
3. Send in headerSignature travels in Stripe-Signature, X-Hub-Signature-256, X-Signature, etc.The secret itself never crosses the wire, so interception reveals nothing reusable
4. RecomputeReceiver runs the identical HMAC over the received raw bodyAny tampering in transit yields a different digest, exposing the change (integrity)
5. Constant-time compareCompare with timingSafeEqual / compare_digest, not ==A byte-by-byte == leaks timing that lets an attacker forge a valid signature
6. Check timestampReject signatures whose signed timestamp is older than ~5 minutesHMAC alone doesn't stop replay; a signed timestamp expires captured requests

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

The canonical verify function (Node and Python)

Before the provider-specific quirks, here is the pattern every correct implementation reduces to. Read the raw body, recompute the HMAC with the shared secret, and compare in constant time. This is the generic form for a provider that sends a plain hex signature in an X-Signature header.

Node.js:

const crypto = require('crypto');

function verifyWebhook(rawBody, receivedSignature, secret) {
  // rawBody must be the exact bytes received (a Buffer or raw string), NOT re-serialized JSON
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)          // no re-stringify — sign the raw bytes
    .digest('hex');

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(receivedSignature, 'utf8');

  // Length check first: timingSafeEqual throws if the buffers differ in length
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

// Express: capture the raw body so the signature can be verified
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-signature'];
  if (!verifyWebhook(req.body, signature, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }
  res.status(200).send('OK'); // acknowledge fast, process async
});

Python:

import hashlib
import hmac

def verify_webhook(raw_body: bytes, received_signature: str, secret: str) -> bool:
    # raw_body must be bytes exactly as received — do not json.loads then re-dump
    expected = hmac.new(
        secret.encode("utf-8"),
        raw_body,
        hashlib.sha256,
    ).hexdigest()

    # compare_digest is constant-time; never use ==
    return hmac.compare_digest(expected, received_signature)


# FastAPI: read the raw body before any JSON parsing
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

@app.post("/webhook")
async def webhook(request: Request):
    raw_body = await request.body()          # raw bytes
    signature = request.headers.get("x-signature", "")
    if not verify_webhook(raw_body, signature, WEBHOOK_SECRET):
        raise HTTPException(status_code=401, detail="Invalid signature")
    return {"ok": True}

Both do the same three things: hash the raw bytes, use HMAC-SHA256, and compare with a constant-time function (crypto.timingSafeEqual / hmac.compare_digest). Everything provider-specific below is a variation on the encoding (hex vs Base64), the header name, and whether a timestamp is folded into the signed string.

You can reproduce these digests by hand to sanity-check your endpoint — paste a payload and secret into the hash generator below, switch it to HMAC / SHA-256, and compare the hex output against what your code computes:

Loading interactive tool...

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.

Advertisement

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

Webhook verification checklist

Run through this before you ship a webhook endpoint to production:

  • Read the raw request body as bytes before any JSON parsing (express.raw(), await request.body()), and HMAC those exact bytes.
  • Use the provider's algorithm and encoding — usually HMAC-SHA256, hex for GitHub/Stripe, Base64 for Shopify. Match it exactly.
  • Use the dedicated webhook signing secret (e.g. whsec_...), not your API key.
  • Compare in constant time with crypto.timingSafeEqual (Node) or hmac.compare_digest (Python) — never == or ===.
  • Include the timestamp in the signed string where the provider does (Stripe signs timestamp.payload), and reject signatures older than ~5 minutes.
  • Enforce idempotency — process each unique event ID once so retries and replays don't double-fire side effects.
  • Reject invalid signatures with 401/403 and stop processing immediately.
  • Acknowledge fast (2xx) and process asynchronously so the provider doesn't time out and re-deliver.
  • Serve the endpoint over HTTPS only and store the secret in an environment variable or secret manager.
  • Log every attempt (success and failure) and alert if the invalid-signature rate spikes.

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.

Frequently Asked Questions

What is HMAC and why is it used for webhooks?

HMAC (Hash-based Message Authentication Code) is a cryptographic method that uses a shared secret key to generate a hash of the webhook payload. It verifies both the authenticity of the sender and the integrity of the payload in one step: only a party holding the secret can produce a valid signature, and any change to the payload produces a completely different signature. That combination is what stops an attacker from forging or tampering with webhook requests.

How do I verify a webhook signature in Python?

Read the raw request body as bytes, recompute the HMAC with hmac.new(secret, raw_body, hashlib.sha256).hexdigest(), and compare it to the signature from the header using hmac.compare_digest(). Never use == to compare — hmac.compare_digest() runs in constant time and prevents timing attacks. Use the exact raw bytes of the body, not a re-serialized JSON object, or the signature will never match.

Which header contains the webhook signature?

It depends on the provider. Stripe sends Stripe-Signature (a timestamp t= plus the HMAC v1=), GitHub sends X-Hub-Signature-256 (prefixed with sha256=), and Shopify sends X-Shopify-Hmac-SHA256 (Base64-encoded). Many smaller providers use a generic X-Signature or X-Webhook-Signature header. Always check your provider's docs for the exact header name and encoding.

What's the difference between SHA256 and SHA1 signatures?

SHA256 produces a 256-bit (64-character hex) hash and is the modern standard, while SHA1 produces a 160-bit (40-character hex) hash and is being phased out. Most providers now use SHA256, though some legacy systems like older Twilio integrations still use SHA1. HMAC-SHA1 is not immediately broken because the secret key defends against the known SHA1 collision attacks, but you should migrate to SHA256 where you can.

Why must I verify against the raw request body?

HMAC signs the exact bytes the provider sent. If your framework parses the JSON and you then re-serialize it, whitespace, key order, or Unicode escaping can change even though the data is identical — and the recomputed signature will not match. Capture the raw body before any JSON parsing (for example express.raw() in Node or await request.body() in FastAPI) and feed those exact bytes into the HMAC.

How do I protect against webhook replay attacks?

HMAC alone does not stop replay, because a captured valid request is still valid if resent. Protect against it by signing and checking a timestamp (reject requests older than about five minutes, the Stripe approach), using a nonce or the provider's unique event ID to process each event only once (idempotency), and logging all webhook attempts. Timestamp checks need no storage; idempotency needs a store like Redis or a database.

What is a timing-safe comparison and why does it matter?

A timing-safe (constant-time) comparison takes the same amount of time whether the strings match early or late, so it does not leak how many leading characters were correct. A normal == comparison returns as soon as it finds a mismatch, and an attacker measuring those tiny timing differences can reconstruct a valid signature byte by byte. Use crypto.timingSafeEqual() in Node or hmac.compare_digest() in Python instead of ==.

Why does signature verification fail even with the correct secret?

The usual culprits are the wrong algorithm (SHA256 vs SHA1), signing a modified or re-serialized payload instead of the raw bytes, the wrong encoding (hex vs Base64), forgetting to include the timestamp in the signed string (Stripe), or using the API key instead of the dedicated webhook signing secret. Log the raw payload length, the received signature, and your computed signature side by side to spot which one differs.

What should my webhook endpoint return?

Return a 2xx status code (typically 200 or 204) as quickly as possible to acknowledge receipt, and do the real work asynchronously in a background queue. Return 401 or 403 for an invalid signature, 4xx for client errors that should not be retried, and 5xx for transient server errors that the provider should retry. Slow endpoints cause providers to time out and re-deliver, creating duplicate events.

webhookssecurityHMACAPIauthenticationcryptography