## 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.
Related Articles

When Is MD5 Still Acceptable? Understanding Non-Security Use Cases
While MD5 is cryptographically broken for security purposes, it remains perfectly acceptable for specific non-security applications. Learn when MD5 is still appropriate and when you must use stronger alternatives.
📄
MTBF vs MTTR: Understanding System Reliability Metrics
Learn the difference between MTBF and MTTR, two critical reliability metrics.
📄
PagerDuty Webhooks: Complete Incident Management Guide [2025]
Complete guide to PagerDuty webhooks for incident management automation.