Stripe Webhook Signature Validation: Troubleshooting Guide
Overview
This document details the comprehensive troubleshooting process for Stripe webhook signature validation issues in Cloudflare Workers, including the critical fixes needed for proper implementation and testing.
Problem Statement
Initial Issue
Stripe webhooks were failing signature validation, returning 400 errors instead of processing payment events. This prevented confirmation emails from being sent after successful payments.
Symptoms
- Webhook endpoint returning 400 "Invalid signature" errors
- Real Stripe webhook deliveries failing validation
- Test harness with fake signatures also failing
- Inconsistent behavior between test and production environments
Root Cause Analysis
1. Cloudflare Workers WebCrypto Compatibility
Problem
Standard Node.js Stripe webhook validation doesn't work in Cloudflare Workers due to different crypto implementations.
Manifestation
// ❌ This fails in Cloudflare Workers
event = stripe.webhooks.constructEvent(
body,
signature,
env.STRIPE_WEBHOOK_SECRET
);Solution
Use Cloudflare Workers-specific async webhook verification:
// ✅ Correct implementation for Cloudflare Workers
if (stripe.webhooks.constructEventAsync) {
const cryptoProvider = stripe.createSubtleCryptoProvider ?
stripe.createSubtleCryptoProvider() :
undefined;
event = await stripe.webhooks.constructEventAsync(
body, // Raw text body
signature, // Stripe signature header
env.STRIPE_WEBHOOK_SECRET, // Webhook secret
undefined, // Tolerance (use default)
cryptoProvider // WebCrypto provider for Workers
);
}2. Request Body Handling Issues
Problem
Body modification during request processing corrupted the signature validation.
Manifestation
// ❌ This can modify the body and break signatures
const body = await request.text();Solution
Use ArrayBuffer for raw body preservation:
// ✅ Correct body handling that preserves original bytes
const rawBody = await request.arrayBuffer();
const body = new TextDecoder().decode(rawBody);3. Test Harness Signature Generation
Problem
Test harness was using fake signatures that didn't match Stripe's HMAC-SHA256 format.
Original Fake Implementation
// ❌ Fake signature that always fails validation
const fakeSignature = `t=${timestamp},v1=fake_signature_${Date.now()}`;Solution: Real HMAC-SHA256 Generation
// ✅ Generate real Stripe-compatible signatures
const generateWebhookSignature = async (payload, secret) => {
const timestamp = Math.floor(Date.now() / 1000);
const signedPayload = `${timestamp}.${payload}`;
// Use Web Crypto API for HMAC-SHA256
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const payloadData = encoder.encode(signedPayload);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, payloadData);
const signatureHex = Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
return `t=${timestamp},v1=${signatureHex}`;
};Complete Implementation Guide
1. Webhook Handler Implementation
Full Working Implementation
export async function handleWebhook(request, env, stripe) {
try {
// CRITICAL: Get raw body as ArrayBuffer first, then convert to text
const rawBody = await request.arrayBuffer();
const body = new TextDecoder().decode(rawBody);
// Get webhook signature from headers
const signature = request.headers.get('stripe-signature');
if (!signature) {
throw new WebhookError(
ERROR_CODES.WEBHOOK_INVALID_SIGNATURE,
'Missing Stripe signature',
{ signature: 'missing' }
);
}
// Validate webhook secret format
if (!env.STRIPE_WEBHOOK_SECRET || !env.STRIPE_WEBHOOK_SECRET.startsWith('whsec_')) {
throw new WebhookError(
ERROR_CODES.CONFIGURATION_ERROR,
'Invalid webhook secret configuration',
{ secretFormat: 'invalid' }
);
}
// Verify webhook signature with proper Cloudflare Workers support
let event;
try {
// CRITICAL: For Cloudflare Workers, use constructEventAsync with WebCrypto
if (stripe.webhooks.constructEventAsync) {
const cryptoProvider = stripe.createSubtleCryptoProvider ?
stripe.createSubtleCryptoProvider() :
undefined;
event = await stripe.webhooks.constructEventAsync(
body,
signature,
env.STRIPE_WEBHOOK_SECRET,
undefined, // Tolerance (use default)
cryptoProvider // WebCrypto provider for Workers
);
} else {
// Fallback to synchronous verification (shouldn't happen in Workers)
event = stripe.webhooks.constructEvent(
body,
signature,
env.STRIPE_WEBHOOK_SECRET
);
}
} catch (error) {
console.error('Webhook signature verification failed:', error);
throw new WebhookError(
ERROR_CODES.WEBHOOK_INVALID_SIGNATURE,
`Webhook signature verification failed: ${error.message}`,
{
signatureHeader: signature?.substring(0, 50) + '...',
bodyLength: body.length,
errorDetails: error.message
}
);
}
// Process the validated event
return await processWebhookEvent(event, env);
} catch (error) {
console.error('Webhook processing failed:', error);
return ErrorHandler.createErrorResponse(error, error, env);
}
}2. Test Harness Implementation
Complete Test Harness with Real Signatures
export const WebhookTestHarness = () => {
const [webhookSecret, setWebhookSecret] = useState('');
// Generate real Stripe webhook signature using Web Crypto API
const generateWebhookSignature = async (payload, secret) => {
if (!secret || !secret.startsWith('whsec_')) {
throw new Error('Invalid webhook secret format. Must start with "whsec_"');
}
const timestamp = Math.floor(Date.now() / 1000);
const signedPayload = `${timestamp}.${payload}`;
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const payloadData = encoder.encode(signedPayload);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, payloadData);
const signatureHex = Array.from(new Uint8Array(signature))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
return `t=${timestamp},v1=${signatureHex}`;
};
const testWebhook = async () => {
try {
// Create test payload
const testPayload = {
id: `evt_test_${Date.now()}`,
object: 'event',
type: 'payment_intent.succeeded',
data: {
object: {
id: `pi_test_${Date.now()}`,
amount: 50000,
currency: 'usd',
status: 'succeeded',
metadata: {
userId: `test_user_${Date.now()}`,
serviceType: 'market-intelligence'
}
}
}
};
const payloadString = JSON.stringify(testPayload);
const stripeSignature = await generateWebhookSignature(payloadString, webhookSecret);
// Send webhook request with real signature
const response = await fetch('https://payments.stratiqx.ai/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'stripe-signature': stripeSignature,
},
body: payloadString,
});
// Handle response
if (response.status === 200) {
console.log('✅ Webhook signature validation succeeded!');
} else {
console.error('❌ Webhook failed:', response.status);
}
} catch (error) {
console.error('Test failed:', error);
}
};
return (
<div>
<input
type="password"
placeholder="Enter webhook secret (whsec_...)"
value={webhookSecret}
onChange={(e) => setWebhookSecret(e.target.value)}
/>
<button onClick={testWebhook}>
Test Webhook with Real Signature
</button>
</div>
);
};3. CORS Configuration for Headers
Problem
CORS policy blocking stripe-signature header in cross-origin requests.
Solution
Update CORS headers to allow Stripe signature header:
// In webhook response and error responses
export const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-User-ID, X-Request-ID, x-request-id, x-client-version, stripe-signature',
'Access-Control-Expose-Headers': 'X-Request-ID, X-Error-Code, X-Response-Time, X-Service-Version, X-Cache',
'Access-Control-Max-Age': '86400'
};Debugging Methodology
1. Comprehensive Logging Strategy
Step-by-Step Validation Logging
console.log('🚀 Webhook processing started at:', new Date().toISOString());
console.log('✅ Step 1: Body read successfully, length:', body.length);
console.log('✅ Step 2: Signature header present, length:', signature.length);
console.log('✅ Step 3: Webhook secret format valid');
// Enhanced debug logging
console.log('🔍 Webhook verification debug:', {
hasSignature: !!signature,
signatureLength: signature?.length,
bodyLength: body.length,
bodyStart: body.substring(0, 100),
hasWebhookSecret: !!env.STRIPE_WEBHOOK_SECRET,
webhookSecretFormat: env.STRIPE_WEBHOOK_SECRET?.substring(0, 6),
contentType: request.headers.get('content-type'),
userAgent: request.headers.get('user-agent')
});Signature Validation Error Logging
catch (error) {
console.error('❌ Webhook signature verification failed');
console.error('Detailed error info:', {
errorMessage: error.message,
errorType: error.type,
errorCode: error.code,
signatureHeader: signature?.substring(0, 50) + '...',
bodyLength: body.length
});
// Specific error guidance
if (error.message.includes('No signatures found matching')) {
console.error('🔍 Signature mismatch - possible causes:');
console.error(' 1. Webhook secret mismatch between Stripe and worker');
console.error(' 2. Request body was modified in transit');
console.error(' 3. Clock skew between servers');
console.error(' 4. Using wrong environment secret (test vs live)');
}
}2. Test Harness Validation
Secret Format Validation
const validateWebhookSecret = (secret) => {
if (!secret || !secret.startsWith('whsec_')) {
throw new Error('Invalid webhook secret format. Must start with "whsec_"');
}
return true;
};Real-time Success/Failure Feedback
const addLog = (level, message, step) => {
const logEntry = {
timestamp: new Date().toLocaleTimeString(),
level, // 'info', 'success', 'error', 'warning'
message,
step // 'SIGNATURE', 'VALIDATION', 'RESPONSE'
};
setLogs(prev => [...prev, logEntry]);
};
// Usage examples
addLog('info', '🔍 Generating real Stripe webhook signature', 'SIGNATURE');
addLog('success', '✅ Generated signature: t=1234567890,v1=abc123...', 'SIGNATURE');
addLog('error', '❌ Webhook rejected: Invalid signature', 'VALIDATION');Environment-Specific Configurations
1. Development Environment
// Development webhook endpoint for testing
const webhookUrl = 'http://localhost:8787/webhook';
// Development webhook secret (from Stripe Dashboard)
const devWebhookSecret = 'whsec_dev_1234567890abcdef...';2. Production Environment
// Production webhook endpoint
const webhookUrl = 'https://payments.stratiqx.ai/webhook';
// Production webhook secret (set via Wrangler)
wrangler secret put STRIPE_WEBHOOK_SECRET --env production3. Secret Management
# Set webhook secret for specific environment
echo "whsec_1234567890abcdef..." | wrangler secret put STRIPE_WEBHOOK_SECRET --env production
# Verify secret is set
wrangler secret list --env production
# Check environment configuration
wrangler tail --env production --format prettyCommon Issues and Solutions
1. Clock Skew Issues
Problem
Timestamp in signature doesn't match server time.
Detection
if (error.message.includes('timestamp')) {
console.error('⏰ Clock skew detected - check server time sync');
}Solution
- Ensure server time is properly synchronized
- Use default tolerance in Stripe webhook verification
- Check for timezone issues in timestamp generation
2. Body Encoding Issues
Problem
Request body encoding affects signature calculation.
Detection
console.log('Body encoding check:', {
bodyType: typeof body,
bodyLength: body.length,
firstBytes: body.substring(0, 20),
encoding: request.headers.get('content-encoding')
});Solution
- Always use ArrayBuffer → TextDecoder for body reading
- Preserve original byte sequence
- Avoid JSON.parse/stringify cycles before validation
3. Environment Secret Mismatches
Problem
Using test webhook secret with live events or vice versa.
Detection
const isTestSecret = env.STRIPE_WEBHOOK_SECRET.includes('test');
const isLiveEvent = event.livemode;
if (isTestSecret && isLiveEvent) {
console.warn('⚠️ Using test secret with live event');
}Solution
- Use environment-specific secrets
- Validate secret format in webhook handler
- Clear environment variable documentation
4. CORS Preflight Failures
Problem
Browser CORS preventing stripe-signature header.
Detection
// Check for CORS preflight request
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 200,
headers: corsHeaders
});
}Solution
- Add stripe-signature to allowed headers
- Handle OPTIONS preflight requests
- Deploy test harness to same domain as API
Testing Strategy
1. Unit Tests for Signature Generation
// Test signature generation matches Stripe's implementation
describe('Webhook Signature Generation', () => {
test('generates valid HMAC-SHA256 signature', async () => {
const payload = '{"test": "data"}';
const secret = 'whsec_test_secret';
const signature = await generateWebhookSignature(payload, secret);
expect(signature).toMatch(/^t=\d+,v1=[a-f0-9]{64}$/);
});
test('signature validates with Stripe library', () => {
// Use Stripe's own validation to verify our signatures
const event = stripe.webhooks.constructEvent(
payload,
signature,
secret
);
expect(event.type).toBe('payment_intent.succeeded');
});
});2. Integration Tests
// Test complete webhook flow
describe('Webhook Integration', () => {
test('processes valid webhook with real signature', async () => {
const testPayload = createTestPaymentIntent();
const signature = await generateWebhookSignature(
JSON.stringify(testPayload),
process.env.TEST_WEBHOOK_SECRET
);
const response = await fetch('/webhook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'stripe-signature': signature
},
body: JSON.stringify(testPayload)
});
expect(response.status).toBe(200);
});
});3. Manual Testing Checklist
- [ ] Test with real webhook secret from Stripe Dashboard
- [ ] Test with both test and live webhook endpoints
- [ ] Verify signature generation produces valid HMAC-SHA256
- [ ] Test CORS handling for cross-origin requests
- [ ] Test error handling for invalid signatures
- [ ] Test error handling for malformed payloads
- [ ] Verify logging provides useful debugging information
Best Practices
1. Security
- Never log webhook secrets or signatures in full
- Use environment variables for all secrets
- Validate webhook secret format before use
- Implement proper CORS policies
2. Error Handling
- Provide specific error messages for different failure types
- Log detailed debugging information
- Don't expose internal errors to external callers
- Implement retry logic for transient failures
3. Monitoring
- Log signature validation success/failure rates
- Monitor webhook processing latency
- Alert on signature validation failures
- Track webhook secret rotation
4. Development Workflow
- Use test harness with real signature generation
- Test against both development and production endpoints
- Validate webhook configuration in Stripe Dashboard
- Document webhook secret management procedures
Quick Reference
Essential Commands
# Monitor webhook processing
wrangler tail stratiqx-payment-prod --format pretty
# Set webhook secret
echo "whsec_..." | wrangler secret put STRIPE_WEBHOOK_SECRET --env production
# Test webhook endpoint
curl -X POST https://payments.stratiqx.ai/webhook \
-H "Content-Type: application/json" \
-H "stripe-signature: t=123,v1=abc..." \
-d '{"test": "data"}'Key Environment Variables
[env.production.vars]
AUTH_BASE_URL = "https://auth.stratiqx.ai"
ENVIRONMENT = "production"
# Secrets (set with wrangler secret put)
# STRIPE_WEBHOOK_SECRET = "whsec_..."
# STRIPE_SECRET_KEY = "sk_..."Critical Headers
// Required for webhook requests
headers: {
'Content-Type': 'application/json',
'stripe-signature': 't=1234567890,v1=abcdef...'
}
// Required for CORS
headers: {
'Access-Control-Allow-Headers': '..., stripe-signature'
}Conclusion
Proper Stripe webhook signature validation in Cloudflare Workers requires:
- Correct WebCrypto Implementation: Use
constructEventAsyncwith WebCrypto provider - Proper Body Handling: Use ArrayBuffer → TextDecoder to preserve original bytes
- Real Signature Generation: Implement proper HMAC-SHA256 for testing
- Comprehensive Logging: Debug every step of the validation process
- Environment Management: Proper secret configuration and CORS setup
This approach ensures reliable webhook processing and makes debugging much easier when issues arise. The test harness with real signature generation is particularly valuable for validating the complete flow without relying on external webhook deliveries.