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
## 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**: ```javascript 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**: ```javascript 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**: ```javascript 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**: ```javascript 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**: ```javascript 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: ```javascript 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: ```javascript 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 ```javascript // ❌ 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 ```javascript // ❌ 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 ```javascript // ❌ 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 ```javascript // ❌ 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 ```javascript // ❌ 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 ```javascript 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 ```javascript // 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 ```javascript // ❌ 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 ```javascript 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 ```javascript 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 ```javascript 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 ```javascript 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](/tools/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: ```bash # 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 ```javascript 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 ```javascript 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 ```javascript // 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 ```javascript // 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: ```javascript // 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](/tools/webhook-payload-generator) to create test webhooks with valid HMAC signatures for Stripe, GitHub, Shopify, and other providers.

Need Expert IT & Security Guidance?

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