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:
- Forged Webhooks: Attackers send fake payment confirmation webhooks to your e-commerce platform, granting access to premium features without payment
- Replay Attacks: Captured legitimate webhooks are resent repeatedly to trigger duplicate actions (e.g., multiple refunds for a single transaction)
- Parameter Tampering: Webhook payloads are intercepted and modified in transit to change amounts, user IDs, or event types
- 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):
- Provider concatenates webhook payload with metadata (often timestamp, webhook ID)
- Provider generates a signature by hashing this data with a signing key
- Provider sends the webhook with the signature in an HTTP header
- Receiver retrieves the signature from the header
Receiver Side (Your Application):
- Extract the signature from the webhook request header
- Retrieve the raw request body exactly as received (critical—no parsing or modifications)
- Reconstruct the signed data using the same format (payload + metadata)
- Generate your own signature using the same algorithm and your copy of the signing key
- Compare your computed signature with the received signature using timing-safe comparison
- Validate additional constraints (timestamp freshness, webhook ID uniqueness)
- 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
| Provider | Algorithm | Header Name | Format | Timestamp Validation |
|---|---|---|---|---|
| Stripe | HMAC-SHA256 | Stripe-Signature | t={timestamp},v1={signature} | Yes (required) |
| GitHub | HMAC-SHA256 | X-Hub-Signature-256 | sha256={signature} | Optional |
| Shopify | HMAC-SHA256 | X-Shopify-Hmac-SHA256 | Base64 encoded | No |
| PayPal | RSA-SHA256 | Paypal-Transmission-Sig | Base64 encoded | Yes |
| Twilio | HMAC-SHA1 | X-Twilio-Signature | Base64 encoded | No |
| Square | HMAC-SHA256 | X-Square-Signature | Base64 encoded | No |
| Discord | HMAC-SHA256 | X-Signature-Ed25519 | Hex encoded | Yes |
| Slack | HMAC-SHA256 | X-Slack-Signature | v0={signature} | Yes (required) |
| SendGrid | ECDSA | X-Twilio-Email-Event-Webhook-Signature | Base64 encoded | Yes |
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:
- Always verify signatures before processing webhook data
- Preserve raw request bodies - signature verification requires exact bytes
- Use timing-safe comparison functions to prevent timing attacks
- Validate timestamps to prevent replay attacks (5-minute tolerance)
- Choose the right algorithm - HMAC-SHA256 for most use cases, RSA/ECDSA when symmetric secrets aren't viable
- Test thoroughly - use tools like our Webhook Payload Generator to validate your implementation
- Follow provider documentation - each provider has specific signature formats and requirements
- Monitor failures - log verification failures to detect potential attacks
- Rotate secrets periodically - implement 90-day rotation with grace periods
- 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.
