Skip to content

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

javascript
// ❌ This fails in Cloudflare Workers
event = stripe.webhooks.constructEvent(
  body,
  signature,
  env.STRIPE_WEBHOOK_SECRET
);

Solution

Use Cloudflare Workers-specific async webhook verification:

javascript
// ✅ 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

javascript
// ❌ This can modify the body and break signatures
const body = await request.text();

Solution

Use ArrayBuffer for raw body preservation:

javascript
// ✅ 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

javascript
// ❌ Fake signature that always fails validation
const fakeSignature = `t=${timestamp},v1=fake_signature_${Date.now()}`;

Solution: Real HMAC-SHA256 Generation

javascript
// ✅ 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

javascript
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

javascript
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:

javascript
// 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

javascript
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

javascript
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

javascript
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

javascript
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

javascript
// 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

javascript
// Production webhook endpoint
const webhookUrl = 'https://payments.stratiqx.ai/webhook';

// Production webhook secret (set via Wrangler)
wrangler secret put STRIPE_WEBHOOK_SECRET --env production

3. Secret Management

bash
# 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 pretty

Common Issues and Solutions

1. Clock Skew Issues

Problem

Timestamp in signature doesn't match server time.

Detection

javascript
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

javascript
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

javascript
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

javascript
// 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

javascript
// 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

javascript
// 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

bash
# 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

toml
[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

javascript
// 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:

  1. Correct WebCrypto Implementation: Use constructEventAsync with WebCrypto provider
  2. Proper Body Handling: Use ArrayBuffer → TextDecoder to preserve original bytes
  3. Real Signature Generation: Implement proper HMAC-SHA256 for testing
  4. Comprehensive Logging: Debug every step of the validation process
  5. 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.

Strategic Intelligence Hub Documentation