Feature Proposal: HMAC Signature Verification for Webhook Node

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

  1. Complexity: Implementing HMAC validation requires understanding cryptography, timing attacks, and replay attack prevention

  2. Security Risk: Users may implement insecure validation (no timing-safe comparison, no timestamp validation)

  3. Maintenance: Each webhook provider has different signature formats requiring custom implementation

  4. 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:

  1. Multiple algorithm support (SHA-256, SHA-512, SHA-1)

  2. Configurable signature header name

  3. Multiple signature format support (raw hash, timestamped, prefixed)

  4. Optional timestamp validation (replay attack prevention)

  5. 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

  1. Timing-safe comparison: Uses crypto.timingSafeEqual() to prevent timing attacks

  2. Replay attack prevention: Optional timestamp validation with configurable tolerance

  3. Secret storage: Secrets stored in n8n’s encrypted credential storage

  4. Error messages: Detailed errors in logs but generic 401 responses to clients

  5. 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
  1. In your Webhook node, set Authentication to “HMAC Signature”

  2. Choose your provider from Provider Preset

  3. Enter your Shared Secret (get this from your provider’s dashboard)

  4. 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:

  1. Set Provider Preset to “Custom”

  2. 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

  1. Priority: Should we prioritize certain provider presets?

  2. Naming: Is “HMAC Signature” clear enough for users?

  3. Defaults: Should timestamp validation be enabled by default for timestamped formats?

  4. Error Messages: How verbose should validation errors be in UI?