Comprehensive guide to securing Stateset webhooks with signature verification, replay protection, and security best practices
Webhook Secret Configuration
# .env
STATESET_WEBHOOK_SECRET=whsec_3rK9pL7nQ2xS5mT8...
WEBHOOK_ENDPOINT_URL=https://api.yourstore.com/webhooks/stateset
# Security Settings
WEBHOOK_TIMEOUT_MS=5000
MAX_WEBHOOK_BODY_SIZE=1048576 # 1MB
ENABLE_REPLAY_PROTECTION=true
REPLAY_TOLERANCE_SECONDS=300 # 5 minutes
HTTPS Requirements
// webhook-server.js
import express from 'express';
import https from 'https';
import fs from 'fs';
const app = express();
// Production HTTPS setup
if (process.env.NODE_ENV === 'production') {
const options = {
key: fs.readFileSync('/path/to/private-key.pem'),
cert: fs.readFileSync('/path/to/certificate.pem'),
// Additional security headers
secureProtocol: 'TLSv1_2_method',
ciphers: [
'ECDHE-RSA-AES128-GCM-SHA256',
'ECDHE-RSA-AES256-GCM-SHA384',
'ECDHE-RSA-AES128-SHA256',
'ECDHE-RSA-AES256-SHA384'
].join(':'),
honorCipherOrder: true
};
https.createServer(options, app).listen(443, () => {
console.log('Secure webhook server running on port 443');
});
} else {
app.listen(3000, () => {
console.log('Development webhook server running on port 3000');
});
}
Request Validation Middleware
import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
// Security middleware
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'none'"],
styleSrc: ["'none'"],
imgSrc: ["'none'"]
}
}
}));
// Rate limiting for webhook endpoints
const webhookLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
max: 100, // Limit each IP to 100 requests per windowMs
message: 'Too many webhook requests from this IP',
standardHeaders: true,
legacyHeaders: false,
skip: (req) => {
// Skip rate limiting for known Stateset IPs
const statesetIPs = ['52.89.214.238', '54.187.174.169', '54.187.205.235'];
return statesetIPs.includes(req.ip);
}
});
app.use('/webhooks', webhookLimiter);
import crypto from 'crypto';
import express from 'express';
class WebhookVerifier {
constructor(secret) {
this.secret = secret;
this.tolerance = 300; // 5 minutes in seconds
}
verifySignature(payload, signature, timestamp) {
try {
// Verify timestamp to prevent replay attacks
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - timestamp) > this.tolerance) {
throw new Error('Webhook timestamp is too old');
}
// Create expected signature
const expectedSignature = this.computeSignature(payload, timestamp);
// Use constant-time comparison to prevent timing attacks
return this.secureCompare(signature, expectedSignature);
} catch (error) {
console.error('Signature verification failed:', error.message);
return false;
}
}
computeSignature(payload, timestamp) {
const signedPayload = `${timestamp}.${payload}`;
return crypto
.createHmac('sha256', this.secret)
.update(signedPayload, 'utf8')
.digest('hex');
}
secureCompare(a, b) {
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
extractSignature(header) {
const elements = header.split(',');
const signatures = {};
for (const element of elements) {
const [key, value] = element.split('=');
if (key === 'v1') {
signatures.v1 = value;
} else if (key === 't') {
signatures.timestamp = parseInt(value, 10);
}
}
return signatures;
}
}
// Initialize verifier
const verifier = new WebhookVerifier(process.env.STATESET_WEBHOOK_SECRET);
// Middleware to capture raw body
app.use('/webhooks/stateset', express.raw({ type: 'application/json', limit: '1mb' }));
// Webhook verification middleware
function verifyWebhook(req, res, next) {
const signature = req.headers['stateset-signature'];
const payload = req.body.toString();
if (!signature) {
return res.status(401).json({ error: 'Missing signature header' });
}
try {
const { v1: sig, timestamp } = verifier.extractSignature(signature);
if (!sig || !timestamp) {
return res.status(401).json({ error: 'Invalid signature format' });
}
const isValid = verifier.verifySignature(payload, sig, timestamp);
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Parse JSON only after verification
req.body = JSON.parse(payload);
next();
} catch (error) {
console.error('Webhook verification error:', error);
return res.status(400).json({ error: 'Webhook verification failed' });
}
}
class AdvancedWebhookVerifier extends WebhookVerifier {
constructor(secret, options = {}) {
super(secret);
this.tolerance = options.tolerance || 300;
this.enableReplayProtection = options.enableReplayProtection !== false;
this.processedWebhooks = new Map(); // In production, use Redis
this.maxCacheSize = options.maxCacheSize || 10000;
}
async verifyWebhookWithReplayProtection(payload, signature, timestamp, webhookId) {
// Check for replay attacks
if (this.enableReplayProtection && this.processedWebhooks.has(webhookId)) {
throw new Error('Webhook has already been processed (replay attack detected)');
}
// Verify signature
const isValid = this.verifySignature(payload, signature, timestamp);
if (isValid && this.enableReplayProtection) {
// Store webhook ID to prevent replay
this.processedWebhooks.set(webhookId, Date.now());
// Clean up old entries to prevent memory leaks
this.cleanupCache();
}
return isValid;
}
cleanupCache() {
if (this.processedWebhooks.size > this.maxCacheSize) {
const cutoff = Date.now() - (this.tolerance * 1000);
for (const [id, timestamp] of this.processedWebhooks.entries()) {
if (timestamp < cutoff) {
this.processedWebhooks.delete(id);
}
}
}
}
// Verify multiple signature versions for backward compatibility
verifyMultipleSignatures(payload, signatureHeader, timestamp) {
const signatures = this.extractSignature(signatureHeader);
// Try v1 signature first
if (signatures.v1) {
const isValid = this.secureCompare(
signatures.v1,
this.computeSignature(payload, timestamp)
);
if (isValid) return true;
}
// Fallback to other versions if needed
// This allows for graceful migration between signature versions
return false;
}
}
// Production webhook handler
const advancedVerifier = new AdvancedWebhookVerifier(
process.env.STATESET_WEBHOOK_SECRET,
{
tolerance: 300,
enableReplayProtection: true,
maxCacheSize: 10000
}
);
async function advancedVerifyWebhook(req, res, next) {
const signature = req.headers['stateset-signature'];
const webhookId = req.headers['stateset-webhook-id'];
const payload = req.body.toString();
if (!signature || !webhookId) {
return res.status(401).json({
error: 'Missing required headers',
required: ['stateset-signature', 'stateset-webhook-id']
});
}
try {
const { v1: sig, timestamp } = advancedVerifier.extractSignature(signature);
const isValid = await advancedVerifier.verifyWebhookWithReplayProtection(
payload,
sig,
timestamp,
webhookId
);
if (!isValid) {
// Log security event
console.warn('Webhook security violation:', {
ip: req.ip,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString(),
webhookId,
signatureProvided: !!signature
});
return res.status(401).json({ error: 'Webhook verification failed' });
}
req.body = JSON.parse(payload);
req.webhookId = webhookId;
next();
} catch (error) {
console.error('Advanced webhook verification error:', error);
return res.status(400).json({ error: error.message });
}
}
class SecureWebhookProcessor {
constructor() {
this.eventHandlers = new Map();
this.processingQueue = [];
this.maxRetries = 3;
this.retryDelays = [1000, 5000, 15000]; // Progressive backoff
}
registerHandler(eventType, handler, options = {}) {
this.eventHandlers.set(eventType, {
handler,
requiresAuth: options.requiresAuth !== false,
timeout: options.timeout || 30000,
retryable: options.retryable !== false
});
}
async processWebhook(webhookData, context = {}) {
const { event, data, id: webhookId } = webhookData;
// Input validation
if (!event || !data) {
throw new Error('Invalid webhook payload structure');
}
const handlerConfig = this.eventHandlers.get(event);
if (!handlerConfig) {
console.warn(`No handler registered for event: ${event}`);
return { status: 'ignored', reason: 'no_handler' };
}
// Security context validation
if (handlerConfig.requiresAuth && !context.authenticated) {
throw new Error('Authentication required for this event type');
}
return await this.executeWithRetry(
handlerConfig,
data,
{ ...context, event, webhookId }
);
}
async executeWithRetry(handlerConfig, data, context) {
let lastError;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
// Set timeout for handler execution
const result = await Promise.race([
handlerConfig.handler(data, context),
this.timeoutPromise(handlerConfig.timeout)
]);
return {
status: 'success',
result,
attempt: attempt + 1
};
} catch (error) {
lastError = error;
console.error(`Webhook handler failed (attempt ${attempt + 1}):`, {
event: context.event,
webhookId: context.webhookId,
error: error.message,
stack: error.stack
});
// Don't retry certain types of errors
if (!handlerConfig.retryable || this.isNonRetryableError(error)) {
break;
}
// Wait before retrying (except on last attempt)
if (attempt < this.maxRetries) {
await this.delay(this.retryDelays[attempt] || 15000);
}
}
}
throw new Error(
`Webhook processing failed after ${this.maxRetries + 1} attempts: ${lastError.message}`
);
}
timeoutPromise(timeout) {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error('Handler timeout')), timeout);
});
}
isNonRetryableError(error) {
const nonRetryablePatterns = [
/validation/i,
/authentication/i,
/authorization/i,
/not found/i,
/duplicate/i
];
return nonRetryablePatterns.some(pattern =>
pattern.test(error.message)
);
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Initialize processor and register handlers
const processor = new SecureWebhookProcessor();
// Order event handlers
processor.registerHandler('order.created', async (orderData, context) => {
// Validate order data
if (!orderData.id || !orderData.customer_email) {
throw new Error('Invalid order data: missing required fields');
}
// Process order creation
console.log(`Processing order creation: ${orderData.id}`);
// Your business logic here
await processNewOrder(orderData);
return { processed: true, orderId: orderData.id };
}, { timeout: 30000 });
processor.registerHandler('order.cancelled', async (orderData, context) => {
// Handle order cancellation
console.log(`Processing order cancellation: ${orderData.id}`);
await processOrderCancellation(orderData);
return { processed: true, orderId: orderData.id };
});
processor.registerHandler('payment.failed', async (paymentData, context) => {
// Handle payment failures (non-retryable)
console.log(`Processing payment failure: ${paymentData.payment_id}`);
await handlePaymentFailure(paymentData);
return { processed: true, paymentId: paymentData.payment_id };
}, { retryable: false });
// Complete secure webhook endpoint
app.post('/webhooks/stateset',
advancedVerifyWebhook,
async (req, res) => {
const startTime = Date.now();
try {
// Add request context
const context = {
ip: req.ip,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString(),
authenticated: true, // Set by verification middleware
webhookId: req.webhookId
};
// Process webhook
const result = await processor.processWebhook(req.body, context);
// Log successful processing
console.log('Webhook processed successfully:', {
event: req.body.event,
webhookId: req.webhookId,
processingTime: Date.now() - startTime,
result: result.status
});
// Return success response
res.status(200).json({
received: true,
processed: result.status === 'success',
webhookId: req.webhookId,
processingTime: Date.now() - startTime
});
} catch (error) {
// Log error with context
console.error('Webhook processing error:', {
event: req.body?.event,
webhookId: req.webhookId,
error: error.message,
stack: error.stack,
processingTime: Date.now() - startTime
});
// Return appropriate error response
const statusCode = error.message.includes('authentication') ? 401 :
error.message.includes('validation') ? 400 : 500;
res.status(statusCode).json({
received: true,
processed: false,
error: error.message,
webhookId: req.webhookId
});
}
}
);
class IPAllowlist {
constructor() {
// Stateset webhook IP ranges (update as needed)
this.allowedRanges = [
'52.89.214.238/32',
'54.187.174.169/32',
'54.187.205.235/32',
// Add more Stateset IP ranges as provided
];
this.compiledRanges = this.compileRanges();
}
compileRanges() {
return this.allowedRanges.map(range => {
const [ip, cidr] = range.split('/');
const mask = ~(0xFFFFFFFF >>> parseInt(cidr, 10));
return {
network: this.ipToInt(ip) & mask,
mask: mask
};
});
}
ipToInt(ip) {
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0;
}
isAllowed(ip) {
const ipInt = this.ipToInt(ip);
return this.compiledRanges.some(range =>
(ipInt & range.mask) === range.network
);
}
}
// IP allowlist middleware
const ipAllowlist = new IPAllowlist();
function checkIPAllowlist(req, res, next) {
// Skip in development
if (process.env.NODE_ENV !== 'production') {
return next();
}
const clientIP = req.ip || req.connection.remoteAddress;
if (!ipAllowlist.isAllowed(clientIP)) {
console.warn('Webhook request from unauthorized IP:', {
ip: clientIP,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString()
});
return res.status(403).json({
error: 'IP not allowed',
ip: clientIP
});
}
next();
}
// Apply IP allowlist before webhook processing
app.use('/webhooks/stateset', checkIPAllowlist);
import winston from 'winston';
class WebhookSecurityMonitor {
constructor() {
this.logger = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'webhook-security.log' }),
new winston.transports.Console()
]
});
this.securityEvents = new Map();
this.alertThresholds = {
failedVerifications: 10,
suspiciousIPs: 5,
timeWindow: 300000 // 5 minutes
};
}
logSecurityEvent(eventType, data) {
const event = {
type: eventType,
timestamp: Date.now(),
data: data
};
this.logger.warn('Security event detected', event);
// Track events for alerting
this.trackEventForAlerting(eventType, data);
}
trackEventForAlerting(eventType, data) {
const key = `${eventType}:${data.ip || 'unknown'}`;
const now = Date.now();
if (!this.securityEvents.has(key)) {
this.securityEvents.set(key, []);
}
const events = this.securityEvents.get(key);
events.push(now);
// Clean old events
const cutoff = now - this.alertThresholds.timeWindow;
const recentEvents = events.filter(timestamp => timestamp > cutoff);
this.securityEvents.set(key, recentEvents);
// Check for alert conditions
this.checkAlertConditions(eventType, data, recentEvents.length);
}
checkAlertConditions(eventType, data, eventCount) {
let shouldAlert = false;
if (eventType === 'signature_verification_failed' &&
eventCount >= this.alertThresholds.failedVerifications) {
shouldAlert = true;
}
if (eventType === 'suspicious_ip' &&
eventCount >= this.alertThresholds.suspiciousIPs) {
shouldAlert = true;
}
if (shouldAlert) {
this.sendSecurityAlert(eventType, data, eventCount);
}
}
async sendSecurityAlert(eventType, data, eventCount) {
const alert = {
type: 'webhook_security_alert',
eventType,
count: eventCount,
timeWindow: this.alertThresholds.timeWindow / 1000 / 60, // minutes
data,
timestamp: new Date().toISOString()
};
this.logger.error('Security alert triggered', alert);
// Send to monitoring service (PagerDuty, Slack, etc.)
try {
await this.sendToMonitoringService(alert);
} catch (error) {
console.error('Failed to send security alert:', error);
}
}
async sendToMonitoringService(alert) {
// Example: Send to Slack webhook
if (process.env.SLACK_WEBHOOK_URL) {
const message = {
text: `🚨 Webhook Security Alert: ${alert.eventType}`,
attachments: [{
color: 'danger',
fields: [
{ title: 'Event Type', value: alert.eventType, short: true },
{ title: 'Count', value: alert.count, short: true },
{ title: 'Time Window', value: `${alert.timeWindow} minutes`, short: true },
{ title: 'IP Address', value: alert.data.ip || 'Unknown', short: true }
],
timestamp: Math.floor(Date.now() / 1000)
}]
};
// Send to Slack (implement HTTP request)
// await fetch(process.env.SLACK_WEBHOOK_URL, { ... });
}
}
}
// Initialize security monitor
const securityMonitor = new WebhookSecurityMonitor();
// Enhanced verification middleware with monitoring
function monitoredVerifyWebhook(req, res, next) {
const signature = req.headers['stateset-signature'];
const webhookId = req.headers['stateset-webhook-id'];
const payload = req.body.toString();
const clientIP = req.ip;
if (!signature || !webhookId) {
securityMonitor.logSecurityEvent('missing_headers', {
ip: clientIP,
userAgent: req.headers['user-agent'],
missingHeaders: [
!signature && 'stateset-signature',
!webhookId && 'stateset-webhook-id'
].filter(Boolean)
});
return res.status(401).json({
error: 'Missing required headers'
});
}
try {
const { v1: sig, timestamp } = advancedVerifier.extractSignature(signature);
const isValid = advancedVerifier.verifySignature(payload, sig, timestamp);
if (!isValid) {
securityMonitor.logSecurityEvent('signature_verification_failed', {
ip: clientIP,
userAgent: req.headers['user-agent'],
webhookId,
hasSignature: !!signature,
hasTimestamp: !!timestamp
});
return res.status(401).json({ error: 'Invalid signature' });
}
req.body = JSON.parse(payload);
req.webhookId = webhookId;
next();
} catch (error) {
securityMonitor.logSecurityEvent('verification_error', {
ip: clientIP,
error: error.message,
webhookId
});
return res.status(400).json({ error: 'Webhook verification failed' });
}
}
// webhook-security.test.js
import crypto from 'crypto';
import request from 'supertest';
import app from '../webhook-server.js';
describe('Webhook Security', () => {
const webhookSecret = 'test_webhook_secret';
function createValidSignature(payload, timestamp) {
const signedPayload = `${timestamp}.${payload}`;
const signature = crypto
.createHmac('sha256', webhookSecret)
.update(signedPayload, 'utf8')
.digest('hex');
return `t=${timestamp},v1=${signature}`;
}
describe('Signature Verification', () => {
it('should accept valid signatures', async () => {
const payload = JSON.stringify({ event: 'order.created', data: { id: '123' } });
const timestamp = Math.floor(Date.now() / 1000);
const signature = createValidSignature(payload, timestamp);
const response = await request(app)
.post('/webhooks/stateset')
.set('stateset-signature', signature)
.set('stateset-webhook-id', 'test-webhook-id')
.send(payload)
.expect(200);
expect(response.body.received).toBe(true);
});
it('should reject invalid signatures', async () => {
const payload = JSON.stringify({ event: 'order.created', data: { id: '123' } });
await request(app)
.post('/webhooks/stateset')
.set('stateset-signature', 'invalid-signature')
.set('stateset-webhook-id', 'test-webhook-id')
.send(payload)
.expect(401);
});
it('should reject old timestamps', async () => {
const payload = JSON.stringify({ event: 'order.created', data: { id: '123' } });
const oldTimestamp = Math.floor(Date.now() / 1000) - 600; // 10 minutes ago
const signature = createValidSignature(payload, oldTimestamp);
await request(app)
.post('/webhooks/stateset')
.set('stateset-signature', signature)
.set('stateset-webhook-id', 'test-webhook-id')
.send(payload)
.expect(401);
});
it('should prevent replay attacks', async () => {
const payload = JSON.stringify({ event: 'order.created', data: { id: '123' } });
const timestamp = Math.floor(Date.now() / 1000);
const signature = createValidSignature(payload, timestamp);
const webhookId = 'unique-webhook-id';
// First request should succeed
await request(app)
.post('/webhooks/stateset')
.set('stateset-signature', signature)
.set('stateset-webhook-id', webhookId)
.send(payload)
.expect(200);
// Second request with same ID should fail
await request(app)
.post('/webhooks/stateset')
.set('stateset-signature', signature)
.set('stateset-webhook-id', webhookId)
.send(payload)
.expect(401);
});
});
describe('Rate Limiting', () => {
it('should rate limit excessive requests', async () => {
const payload = JSON.stringify({ event: 'test.event', data: {} });
const timestamp = Math.floor(Date.now() / 1000);
// Make many requests quickly
const requests = Array.from({ length: 150 }, (_, i) => {
const signature = createValidSignature(payload, timestamp);
return request(app)
.post('/webhooks/stateset')
.set('stateset-signature', signature)
.set('stateset-webhook-id', `webhook-${i}`)
.send(payload);
});
const responses = await Promise.allSettled(requests);
const rateLimited = responses.filter(r =>
r.status === 'fulfilled' && r.value.status === 429
);
expect(rateLimited.length).toBeGreaterThan(0);
});
});
describe('Input Validation', () => {
it('should reject oversized payloads', async () => {
const largePayload = 'x'.repeat(2 * 1024 * 1024); // 2MB
const timestamp = Math.floor(Date.now() / 1000);
const signature = createValidSignature(largePayload, timestamp);
await request(app)
.post('/webhooks/stateset')
.set('stateset-signature', signature)
.set('stateset-webhook-id', 'test-webhook-id')
.send(largePayload)
.expect(413); // Payload too large
});
it('should reject malformed JSON', async () => {
const invalidJson = '{ invalid json }';
const timestamp = Math.floor(Date.now() / 1000);
const signature = createValidSignature(invalidJson, timestamp);
await request(app)
.post('/webhooks/stateset')
.set('stateset-signature', signature)
.set('stateset-webhook-id', 'test-webhook-id')
.send(invalidJson)
.expect(400);
});
});
});
Signature Verification
Network Security
Error Handling
Monitoring
# Environment variables for production
STATESET_WEBHOOK_SECRET=whsec_prod_9K2xL5pN...
WEBHOOK_TIMEOUT_MS=30000
MAX_WEBHOOK_BODY_SIZE=1048576
ENABLE_REPLAY_PROTECTION=true
REPLAY_TOLERANCE_SECONDS=300
# Security headers
ENABLE_HELMET=true
ENABLE_RATE_LIMITING=true
ALLOWED_IPS=52.89.214.238,54.187.174.169,54.187.205.235
# Monitoring
ENABLE_SECURITY_MONITORING=true
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/your/slack/webhook
ALERT_EMAIL=security@yourstore.com
# SSL/TLS
SSL_CERT_PATH=/path/to/ssl/cert.pem
SSL_KEY_PATH=/path/to/ssl/private-key.pem