Feature Proposal: HMAC Signature Verification for Webhook Node
Summary
Add native HMAC signature verification to the Webhook node, enabling secure webhook authentication for providers like ElevenLabs, Stripe, GitHub, and Shopify without requiring custom Code nodes.
Problem Statement
Many webhook providers use HMAC-SHA256 signatures to authenticate webhooks and prevent unauthorized requests. Currently, n8n users must implement HMAC validation using:
-
Custom Code nodes (complex for non-technical users)
-
Manual JavaScript in function nodes (error-prone)
-
External validation services (adds latency)
This creates a significant barrier for users who want to securely receive webhooks from services that require HMAC authentication.
Motivation
User Pain Points
-
Complexity: Implementing HMAC validation requires understanding cryptography, timing attacks, and replay attack prevention
-
Security Risk: Users may implement insecure validation (no timing-safe comparison, no timestamp validation)
-
Maintenance: Each webhook provider has different signature formats requiring custom implementation
-
Accessibility: Non-technical users cannot easily configure secure webhooks
Use Cases
-
ElevenLabs: Post-call transcript webhooks (
ElevenLabs-Signature: t=1234567890,v0=abc123...) -
Stripe: Payment webhooks (
Stripe-Signature: t=1492774577,v1=abc123...) -
GitHub: Repository webhooks (
X-Hub-Signature-256: sha256=abc123...) -
Shopify: Order webhooks (
X-Shopify-Hmac-SHA256: abc123...) -
Slack: Event API (
X-Slack-Signature: v0=abc123...)
Proposed Solution
High-Level Approach
Add “HMAC Signature” as a new authentication option in the Webhook node with:
-
Multiple algorithm support (SHA-256, SHA-512, SHA-1)
-
Configurable signature header name
-
Multiple signature format support (raw hash, timestamped, prefixed)
-
Optional timestamp validation (replay attack prevention)
-
Provider presets for popular services
User Interface
Authentication Dropdown
Add new option to existing authentication dropdown:
-
None
-
Basic Auth
-
Header Auth
-
JWT Auth
-
HMAC Signature (NEW)
HMAC Configuration Panel
When “HMAC Signature” is selected, show:
┌─ HMAC Settings ─────────────────────────────────────┐
│ │
│ Provider Preset: [Custom ▼] │
│ ├─ Custom │
│ ├─ ElevenLabs │
│ ├─ Stripe │
│ ├─ GitHub │
│ ├─ Shopify │
│ └─ Slack │
│ │
│ Shared Secret: [••••••••••••••••] (password) │
│ │
│ Algorithm: [SHA-256 ▼] │
│ │
│ Signature Header: [X-Signature ] │
│ │
│ Signature Format: [Raw Hash ▼] │
│ ├─ Raw Hash │
│ ├─ Timestamped (t={},v0={}) │
│ └─ Prefixed (sha256=) │
│ │
│ - Validate Timestamp │
│ │
│ Timestamp Tolerance: [1800] seconds │
│ (Only shown if Validate Timestamp is checked) │
│ │
└─────────────────────────────────────────────────────┘
Technical Implementation
1. HMAC Validator Class
// packages/nodes-base/nodes/Webhook/HmacValidator.ts
import * as crypto from 'crypto';
import { NodeOperationError } from 'n8n-workflow';
export interface IHmacSettings {
sharedSecret: string;
algorithm: 'sha256' | 'sha512' | 'sha1';
signatureHeader: string;
signatureFormat: 'raw' | 'timestamped' | 'prefixed';
validateTimestamp: boolean;
timestampTolerance: number;
}
export interface IHmacValidationResult {
valid: boolean;
error?: string;
errorDetails?: string;
}
export class HmacValidator {
static validate(
rawBody: string,
signatureHeader: string,
settings: IHmacSettings,
): IHmacValidationResult {
try {
// Parse signature based on format
const parsed = this.parseSignature(signatureHeader, settings.signatureFormat);
if (!parsed.success) {
return {
valid: false,
error: 'invalid_signature_format',
errorDetails: parsed.error,
};
}
// Validate timestamp if required
if (settings.validateTimestamp && parsed.timestamp) {
const currentTime = Math.floor(Date.now() / 1000);
const age = currentTime - parsed.timestamp;
if (age > settings.timestampTolerance) {
return {
valid: false,
error: 'timestamp_too_old',
errorDetails: `Request age: ${age}s, tolerance: ${settings.timestampTolerance}s`,
};
}
if (age < 0) {
return {
valid: false,
error: 'timestamp_in_future',
errorDetails: `Request timestamp is ${Math.abs(age)}s in the future`,
};
}
}
// Compute expected signature
const messageToSign = parsed.timestamp
? `${parsed.timestamp}.${rawBody}`
: rawBody;
const expectedSignature = crypto
.createHmac(settings.algorithm, settings.sharedSecret)
.update(messageToSign, 'utf8')
.digest('hex');
// Timing-safe comparison
if (!this.timingSafeEqual(expectedSignature, parsed.signature)) {
return {
valid: false,
error: 'signature_mismatch',
errorDetails: 'Computed signature does not match provided signature',
};
}
return { valid: true };
} catch (error) {
return {
valid: false,
error: 'validation_error',
errorDetails: error instanceof Error ? error.message : 'Unknown error',
};
}
}
private static timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
const bufA = Buffer.from(a, 'utf8');
const bufB = Buffer.from(b, 'utf8');
return crypto.timingSafeEqual(bufA, bufB);
}
private static parseSignature(
header: string,
format: 'raw' | 'timestamped' | 'prefixed',
): { success: boolean; signature?: string; timestamp?: number; error?: string } {
switch (format) {
case 'raw':
// Just the hash: "abc123..."
return { success: true, signature: header.trim() };
case 'timestamped':
// Format: "t=1234567890,v0=abc123..." or "t=1234567890,v1=abc123..."
const timestampedMatch = header.match(/t=(\d+),[v|s]\d*=([a-f0-9]+)/i);
if (!timestampedMatch) {
return {
success: false,
error: 'Invalid timestamped format. Expected: t={timestamp},v0={signature}',
};
}
return {
success: true,
timestamp: parseInt(timestampedMatch[1], 10),
signature: timestampedMatch[2],
};
case 'prefixed':
// Format: "sha256=abc123..." or "v0=abc123..."
const prefixedMatch = header.match(/^(?:sha\d+|v\d+)=([a-f0-9]+)$/i);
if (!prefixedMatch) {
return {
success: false,
error: 'Invalid prefixed format. Expected: sha256={signature} or v0={signature}',
};
}
return { success: true, signature: prefixedMatch[1] };
default:
return { success: false, error: `Unknown format: ${format}` };
}
}
static getPresetConfig(preset: string): Partial<IHmacSettings> | null {
const presets: Record<string, Partial<IHmacSettings>> = {
elevenlabs: {
signatureHeader: 'ElevenLabs-Signature',
signatureFormat: 'timestamped',
algorithm: 'sha256',
validateTimestamp: true,
timestampTolerance: 1800, // 30 minutes
},
stripe: {
signatureHeader: 'Stripe-Signature',
signatureFormat: 'timestamped',
algorithm: 'sha256',
validateTimestamp: true,
timestampTolerance: 300, // 5 minutes
},
github: {
signatureHeader: 'X-Hub-Signature-256',
signatureFormat: 'prefixed',
algorithm: 'sha256',
validateTimestamp: false,
},
shopify: {
signatureHeader: 'X-Shopify-Hmac-SHA256',
signatureFormat: 'raw',
algorithm: 'sha256',
validateTimestamp: false,
},
slack: {
signatureHeader: 'X-Slack-Signature',
signatureFormat: 'prefixed',
algorithm: 'sha256',
validateTimestamp: true,
timestampTolerance: 300, // 5 minutes
},
};
return presets[preset] || null;
}
}
2. Webhook Node Updates
Add to description.ts:
export const hmacAuthenticationSettings: INodeProperties = {
displayName: 'HMAC Settings',
name: 'hmacSettings',
type: 'collection',
displayOptions: {
show: {
authentication: ['hmacSignature'],
},
},
default: {},
options: [
{
displayName: 'Provider Preset',
name: 'preset',
type: 'options',
options: [
{ name: 'Custom', value: 'custom' },
{ name: 'ElevenLabs', value: 'elevenlabs' },
{ name: 'GitHub', value: 'github' },
{ name: 'Shopify', value: 'shopify' },
{ name: 'Slack', value: 'slack' },
{ name: 'Stripe', value: 'stripe' },
],
default: 'custom',
description: 'Use preset configuration for popular webhook providers',
},
{
displayName: 'Shared Secret',
name: 'sharedSecret',
type: 'string',
typeOptions: { password: true },
required: true,
default: '',
description: 'The shared secret key for HMAC verification',
},
{
displayName: 'Algorithm',
name: 'algorithm',
type: 'options',
options: [
{ name: 'SHA-256', value: 'sha256' },
{ name: 'SHA-512', value: 'sha512' },
{ name: 'SHA-1', value: 'sha1' },
],
default: 'sha256',
description: 'The HMAC algorithm to use for signature verification',
},
{
displayName: 'Signature Header Name',
name: 'signatureHeader',
type: 'string',
default: 'X-Signature',
description: 'The HTTP header containing the HMAC signature',
},
{
displayName: 'Signature Format',
name: 'signatureFormat',
type: 'options',
options: [
{
name: 'Raw Hash',
value: 'raw',
description: 'Just the hash value (e.g., "abc123...")',
},
{
name: 'Timestamped',
value: 'timestamped',
description: 'Timestamp and hash (e.g., "t=1234567890,v0=abc123...")',
},
{
name: 'Prefixed',
value: 'prefixed',
description: 'Algorithm prefix and hash (e.g., "sha256=abc123...")',
},
],
default: 'raw',
description: 'The format of the signature header value',
},
{
displayName: 'Validate Timestamp',
name: 'validateTimestamp',
type: 'boolean',
default: false,
description: 'Whether to validate timestamp to prevent replay attacks (only for timestamped format)',
},
{
displayName: 'Timestamp Tolerance',
name: 'timestampTolerance',
type: 'number',
displayOptions: {
show: {
validateTimestamp: [true],
},
},
default: 1800,
description: 'Maximum age of request in seconds (default: 30 minutes)',
},
],
};
Update authenticationProperty:
export const authenticationProperty = (propertyName = 'authentication'): INodeProperties => ({
displayName: 'Authentication',
name: propertyName,
type: 'options',
options: [
{
name: 'Basic Auth',
value: 'basicAuth',
},
{
name: 'Header Auth',
value: 'headerAuth',
},
{
name: 'HMAC Signature',
value: 'hmacSignature',
},
{
name: 'JWT Auth',
value: 'jwtAuth',
},
{
name: 'None',
value: 'none',
},
],
default: 'none',
description: 'The way to authenticate',
});
3. Webhook Execution Updates
Add to utils.ts:
import { HmacValidator } from './HmacValidator';
export function validateHmacAuthentication(
context: IWebhookFunctions,
rawBody: string,
): boolean {
const hmacSettings = context.getNodeParameter('hmacSettings', {}) as IDataObject;
// Apply preset if selected
const preset = hmacSettings.preset as string;
if (preset && preset !== 'custom') {
const presetConfig = HmacValidator.getPresetConfig(preset);
if (presetConfig) {
Object.assign(hmacSettings, presetConfig, {
// Don't override shared secret
sharedSecret: hmacSettings.sharedSecret,
});
}
}
const signatureHeader = context.getHeaderData()[
hmacSettings.signatureHeader as string
] as string;
if (!signatureHeader) {
throw new WebhookAuthorizationError(
401,
`Missing signature header: ${hmacSettings.signatureHeader}`,
);
}
const result = HmacValidator.validate(rawBody, signatureHeader, {
sharedSecret: hmacSettings.sharedSecret as string,
algorithm: hmacSettings.algorithm as 'sha256' | 'sha512' | 'sha1',
signatureHeader: hmacSettings.signatureHeader as string,
signatureFormat: hmacSettings.signatureFormat as 'raw' | 'timestamped' | 'prefixed',
validateTimestamp: hmacSettings.validateTimestamp as boolean,
timestampTolerance: hmacSettings.timestampTolerance as number,
});
if (!result.valid) {
throw new WebhookAuthorizationError(
401,
`HMAC validation failed: ${result.error}${result.errorDetails ? ' - ' + result.errorDetails : ''}`,
);
}
return true;
}
Update validateWebhookAuthentication function:
export async function validateWebhookAuthentication(
context: IWebhookFunctions,
authenticationMethod: string,
): Promise<boolean> {
if (authenticationMethod === 'hmacSignature') {
const rawBody = context.getBodyData().rawBody || '';
return validateHmacAuthentication(context, rawBody);
}
// ... existing authentication methods
}
Security Considerations
-
Timing-safe comparison: Uses
crypto.timingSafeEqual()to prevent timing attacks -
Replay attack prevention: Optional timestamp validation with configurable tolerance
-
Secret storage: Secrets stored in n8n’s encrypted credential storage
-
Error messages: Detailed errors in logs but generic 401 responses to clients
-
Algorithm flexibility: Supports multiple hash algorithms for compatibility
Testing Strategy
Unit Tests
// packages/nodes-base/nodes/Webhook/test/HmacValidator.test.ts
describe('HmacValidator', () => {
describe('validate()', () => {
const testSecret = 'test-secret-key';
it('should accept valid signature (raw format)', () => {
const rawBody = '{"test":"data"}';
const signature = crypto
.createHmac('sha256', testSecret)
.update(rawBody, 'utf8')
.digest('hex');
const result = HmacValidator.validate(rawBody, signature, {
sharedSecret: testSecret,
algorithm: 'sha256',
signatureHeader: 'X-Signature',
signatureFormat: 'raw',
validateTimestamp: false,
timestampTolerance: 0,
});
expect(result.valid).toBe(true);
});
it('should reject invalid signature', () => {
const rawBody = '{"test":"data"}';
const wrongSignature = '0'.repeat(64);
const result = HmacValidator.validate(rawBody, wrongSignature, {
sharedSecret: testSecret,
algorithm: 'sha256',
signatureHeader: 'X-Signature',
signatureFormat: 'raw',
validateTimestamp: false,
timestampTolerance: 0,
});
expect(result.valid).toBe(false);
expect(result.error).toBe('signature_mismatch');
});
it('should accept valid timestamped signature', () => {
const rawBody = '{"test":"data"}';
const timestamp = Math.floor(Date.now() / 1000);
const messageToSign = `${timestamp}.${rawBody}`;
const signature = crypto
.createHmac('sha256', testSecret)
.update(messageToSign, 'utf8')
.digest('hex');
const header = `t=${timestamp},v0=${signature}`;
const result = HmacValidator.validate(rawBody, header, {
sharedSecret: testSecret,
algorithm: 'sha256',
signatureHeader: 'X-Signature',
signatureFormat: 'timestamped',
validateTimestamp: true,
timestampTolerance: 300,
});
expect(result.valid).toBe(true);
});
it('should reject old timestamp', () => {
const rawBody = '{"test":"data"}';
const oldTimestamp = Math.floor(Date.now() / 1000) - 400; // 400 seconds old
const messageToSign = `${oldTimestamp}.${rawBody}`;
const signature = crypto
.createHmac('sha256', testSecret)
.update(messageToSign, 'utf8')
.digest('hex');
const header = `t=${oldTimestamp},v0=${signature}`;
const result = HmacValidator.validate(rawBody, header, {
sharedSecret: testSecret,
algorithm: 'sha256',
signatureHeader: 'X-Signature',
signatureFormat: 'timestamped',
validateTimestamp: true,
timestampTolerance: 300,
});
expect(result.valid).toBe(false);
expect(result.error).toBe('timestamp_too_old');
});
it('should reject tampered payload', () => {
const originalBody = '{"test":"data"}';
const tamperedBody = '{"test":"hacked"}';
const signature = crypto
.createHmac('sha256', testSecret)
.update(originalBody, 'utf8')
.digest('hex');
const result = HmacValidator.validate(tamperedBody, signature, {
sharedSecret: testSecret,
algorithm: 'sha256',
signatureHeader: 'X-Signature',
signatureFormat: 'raw',
validateTimestamp: false,
timestampTolerance: 0,
});
expect(result.valid).toBe(false);
expect(result.error).toBe('signature_mismatch');
});
it('should handle prefixed format (GitHub style)', () => {
const rawBody = '{"test":"data"}';
const signature = crypto
.createHmac('sha256', testSecret)
.update(rawBody, 'utf8')
.digest('hex');
const header = `sha256=${signature}`;
const result = HmacValidator.validate(rawBody, header, {
sharedSecret: testSecret,
algorithm: 'sha256',
signatureHeader: 'X-Hub-Signature-256',
signatureFormat: 'prefixed',
validateTimestamp: false,
timestampTolerance: 0,
});
expect(result.valid).toBe(true);
});
});
describe('getPresetConfig()', () => {
it('should load ElevenLabs preset', () => {
const config = HmacValidator.getPresetConfig('elevenlabs');
expect(config).toEqual({
signatureHeader: 'ElevenLabs-Signature',
signatureFormat: 'timestamped',
algorithm: 'sha256',
validateTimestamp: true,
timestampTolerance: 1800,
});
});
it('should load Stripe preset', () => {
const config = HmacValidator.getPresetConfig('stripe');
expect(config).toEqual({
signatureHeader: 'Stripe-Signature',
signatureFormat: 'timestamped',
algorithm: 'sha256',
validateTimestamp: true,
timestampTolerance: 300,
});
});
it('should return null for unknown preset', () => {
const config = HmacValidator.getPresetConfig('unknown');
expect(config).toBeNull();
});
});
});
Integration Tests
// Test with real webhook scenarios
describe('Webhook HMAC Integration', () => {
it('should accept valid ElevenLabs webhook', async () => {
// Test implementation with mock ElevenLabs payload
});
it('should accept valid Stripe webhook', async () => {
// Test implementation with mock Stripe payload
});
it('should reject webhook without signature', async () => {
// Test implementation
});
});
Documentation
User Documentation
Title: Webhook HMAC Authentication
Content:
The Webhook node supports HMAC signature verification to authenticate incoming webhooks from providers like ElevenLabs, Stripe, GitHub, and Shopify.
Quick Start with Presets
-
In your Webhook node, set Authentication to “HMAC Signature”
-
Choose your provider from Provider Preset
-
Enter your Shared Secret (get this from your provider’s dashboard)
-
Click “Execute Node” - your webhook is now secured!
Supported Providers
| Provider | Preset Name | Documentation |
|---|---|---|
| ElevenLabs | ElevenLabs | Docs |
| Stripe | Stripe | Docs |
| GitHub | GitHub | Docs |
| Shopify | Shopify | Docs |
| Slack | Slack | Docs |
Custom Configuration
For providers not in the preset list:
-
Set Provider Preset to “Custom”
-
Configure:
-
Shared Secret: Your webhook secret
-
Algorithm: Usually SHA-256
-
Signature Header: Check provider docs (e.g., “X-Signature”)
-
Signature Format: How the signature is formatted
-
Validate Timestamp: Enable for replay attack protection
-
Security Best Practices
-
Use environment variables for secrets
-
Enable timestamp validation when available
-
Rotate secrets every 6-12 months
-
Use different secrets for dev/staging/production
-
Monitor for authentication failures
Troubleshooting
401 Unauthorized Errors:
-
Verify shared secret matches your provider
-
Check signature header name is correct
-
Ensure signature format matches provider’s format
-
For timestamped signatures, check system clock is synchronized
Signature Mismatch:
-
Verify you’re using the correct algorithm (usually SHA-256)
-
Check raw body encoding (must be UTF-8)
-
Ensure no middleware is modifying the request body
Backwards Compatibility
-
No breaking changes - existing webhooks continue to work
-
New authentication option is opt-in
-
Existing authentication methods unchanged
-
Node version increment: 2.1 → 2.2
Alternatives Considered
Alternative 1: External Validation Service
-
Pros: Centralized validation
-
Cons: Adds latency, external dependency, privacy concerns
-
Decision: Rejected - native implementation is better
Alternative 2: Code Node Only
-
Pros: Already possible
-
Cons: Complex for users, error-prone, no standardization
-
Decision: Current workaround, but inadequate
Alternative 3: Separate HMAC Validation Node
-
Pros: Separation of concerns
-
Cons: Extra node in workflow, more complex for users
-
Decision: Rejected - authentication should be in Webhook node
Questions for Community
-
Priority: Should we prioritize certain provider presets?
-
Naming: Is “HMAC Signature” clear enough for users?
-
Defaults: Should timestamp validation be enabled by default for timestamped formats?
-
Error Messages: How verbose should validation errors be in UI?