VYPR
Moderate severityOSV Advisory· Published Jan 8, 2026· Updated Jan 8, 2026

n8n's Missing Stripe-Signature Verification Allows Unauthenticated Forged Webhooks

CVE-2026-21894

Description

n8n is an open source workflow automation platform. In versions from 0.150.0 to before 2.2.2, an authentication bypass vulnerability in the Stripe Trigger node allows unauthenticated parties to trigger workflows by sending forged Stripe webhook events. The Stripe Trigger creates and stores a Stripe webhook signing secret when registering the webhook endpoint, but incoming webhook requests were not verified against this secret. As a result, any HTTP client that knows the webhook URL could send a POST request containing a matching event type, causing the workflow to execute as if a legitimate Stripe event had been received. This issue affects n8n users who have active workflows using the Stripe Trigger node. An attacker could potentially fake payment or subscription events and influence downstream workflow behavior. The practical risk is reduced by the fact that the webhook URL contains a high-entropy UUID; however, authenticated n8n users with access to the workflow can view this webhook ID. This issue has been patched in version 2.2.2. A temporary workaround for this issue involves users deactivating affected workflows or restricting access to workflows containing Stripe Trigger nodes to trusted users only.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
n8nnpm
>= 0.150.0, < 2.2.22.2.2

Affected products

1
  • Range: n8n-core@0.100.0, n8n-core@0.101.0, n8n-core@0.102.0, …

Patches

1
a61a5991093c

fix(Stripe Trigger Node): Add Stripe signature verification (#22764)

https://github.com/n8n-io/n8nShireen MissiDec 22, 2025via ghsa
5 files changed · +417 2
  • packages/nodes-base/credentials/StripeApi.credentials.ts+21 0 modified
    @@ -20,6 +20,27 @@ export class StripeApi implements ICredentialType {
     			typeOptions: { password: true },
     			default: '',
     		},
    +		{
    +			displayName: 'Signature Secret',
    +			name: 'signatureSecret',
    +			type: 'string',
    +			typeOptions: { password: true },
    +			default: '',
    +			description:
    +				'The signature secret is used to verify the authenticity of requests sent by Stripe.',
    +		},
    +		{
    +			displayName:
    +				'We strongly recommend setting up a <a href="https://stripe.com/docs/webhooks" target="_blank">signing secret</a> to ensure the authenticity of requests.',
    +			name: 'notice',
    +			type: 'notice',
    +			default: '',
    +			displayOptions: {
    +				show: {
    +					signatureSecret: [''],
    +				},
    +			},
    +		},
     	];
     
     	authenticate: IAuthenticateGeneric = {
    
  • packages/nodes-base/nodes/Stripe/StripeTriggerHelpers.ts+74 0 added
    @@ -0,0 +1,74 @@
    +import { createHmac, timingSafeEqual } from 'crypto';
    +import type { IWebhookFunctions } from 'n8n-workflow';
    +
    +export async function verifySignature(this: IWebhookFunctions): Promise<boolean> {
    +	const credential = await this.getCredentials('stripeApi');
    +	if (!credential?.signatureSecret) {
    +		return true; // No signature secret provided, skip verification
    +	}
    +
    +	const req = this.getRequestObject();
    +
    +	const signature = req.header('stripe-signature');
    +	if (!signature) {
    +		return false;
    +	}
    +
    +	// Parse the Stripe signature header
    +	const elements = signature.split(',');
    +	let timestamp: string | undefined;
    +	let signatureValue: string | undefined;
    +
    +	for (const element of elements) {
    +		if (element.startsWith('t=')) {
    +			timestamp = element.substring(2);
    +		} else if (element.startsWith('v1=')) {
    +			signatureValue = element.substring(3);
    +		}
    +	}
    +
    +	if (!timestamp || !signatureValue) {
    +		return false;
    +	}
    +
    +	// Verify timestamp
    +	const currentTimestamp = Math.floor(Date.now() / 1000);
    +	const webhookTimestamp = parseInt(timestamp, 10);
    +	const TIMESTAMP_TOLERANCE_SECONDS = 300; // 5 minutes
    +
    +	if (Math.abs(currentTimestamp - webhookTimestamp) > TIMESTAMP_TOLERANCE_SECONDS) {
    +		return false;
    +	}
    +
    +	try {
    +		if (typeof credential.signatureSecret !== 'string') {
    +			return false;
    +		}
    +
    +		if (!req.rawBody) {
    +			return false;
    +		}
    +
    +		let rawBodyString: string;
    +		if (Buffer.isBuffer(req.rawBody)) {
    +			rawBodyString = req.rawBody.toString();
    +		} else {
    +			rawBodyString = typeof req.rawBody === 'string' ? req.rawBody : JSON.stringify(req.rawBody);
    +		}
    +
    +		const signedPayload = `${timestamp}.${rawBodyString}`;
    +		const hmac = createHmac('sha256', credential.signatureSecret);
    +		hmac.update(signedPayload);
    +		const computedSignature = hmac.digest('hex');
    +
    +		const computedBuffer = Buffer.from(computedSignature);
    +		const providedBuffer = Buffer.from(signatureValue);
    +
    +		return (
    +			computedBuffer.length === providedBuffer.length &&
    +			timingSafeEqual(computedBuffer, providedBuffer)
    +		);
    +	} catch (error) {
    +		return false;
    +	}
    +}
    
  • packages/nodes-base/nodes/Stripe/StripeTrigger.node.ts+9 1 modified
    @@ -12,6 +12,7 @@ import type {
     import { NodeApiError, NodeConnectionTypes } from 'n8n-workflow';
     
     import { stripeApiRequest } from './helpers';
    +import { verifySignature } from './StripeTriggerHelpers';
     
     export class StripeTrigger implements INodeType {
     	description: INodeTypeDescription = {
    @@ -949,8 +950,15 @@ export class StripeTrigger implements INodeType {
     		const bodyData = this.getBodyData();
     		const req = this.getRequestObject();
     
    -		const events = this.getNodeParameter('events', []) as string[];
    +		if (!(await verifySignature.call(this))) {
    +			const res = this.getResponseObject();
    +			res.status(401).send('Unauthorized').end();
    +			return {
    +				noWebhookResponse: true,
    +			};
    +		}
     
    +		const events = this.getNodeParameter('events', []) as string[];
     		const eventType = bodyData.type as string | undefined;
     
     		if (eventType === undefined || (!events.includes('*') && !events.includes(eventType))) {
    
  • packages/nodes-base/nodes/Stripe/__tests__/StripeTriggerHelpers.test.ts+234 0 added
    @@ -0,0 +1,234 @@
    +import { createHmac } from 'crypto';
    +import type { IWebhookFunctions } from 'n8n-workflow';
    +
    +import { verifySignature } from '../StripeTriggerHelpers';
    +
    +describe('StripeTriggerHelpers', () => {
    +	describe('verifySignature', () => {
    +		let mockWebhookFunctions: IWebhookFunctions;
    +		const webhookSecret = 'whsec_test123456789';
    +		const getCurrentTimestamp = () => Math.floor(Date.now() / 1000).toString();
    +		const testBody = { type: 'charge.succeeded', id: 'ch_123' };
    +		const rawBody = JSON.stringify(testBody);
    +
    +		function generateValidSignature(timestamp: string, body: string, secret: string): string {
    +			const signedPayload = `${timestamp}.${body}`;
    +			const signature = createHmac('sha256', secret).update(signedPayload).digest('hex');
    +			return `t=${timestamp},v1=${signature}`;
    +		}
    +
    +		beforeEach(() => {
    +			mockWebhookFunctions = {
    +				getCredentials: jest.fn().mockResolvedValue({
    +					secretKey: 'sk_test_123',
    +					signatureSecret: webhookSecret,
    +				}),
    +				getRequestObject: jest.fn().mockReturnValue({
    +					header: jest.fn(),
    +					rawBody: Buffer.from(rawBody),
    +				}),
    +			} as unknown as IWebhookFunctions;
    +		});
    +
    +		it('should return true when no signature secret is provided', async () => {
    +			(mockWebhookFunctions.getCredentials as jest.Mock).mockResolvedValue({
    +				secretKey: 'sk_test_123',
    +			});
    +
    +			const result = await verifySignature.call(mockWebhookFunctions);
    +
    +			expect(result).toBe(true);
    +		});
    +
    +		it('should return false when stripe-signature header is missing', async () => {
    +			const mockHeader = jest.fn().mockReturnValue(undefined);
    +			(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
    +				header: mockHeader,
    +				rawBody: Buffer.from(rawBody),
    +			});
    +
    +			const result = await verifySignature.call(mockWebhookFunctions);
    +
    +			expect(result).toBe(false);
    +			expect(mockHeader).toHaveBeenCalledWith('stripe-signature');
    +		});
    +
    +		it('should return false when signature format is invalid', async () => {
    +			const mockHeader = jest.fn().mockReturnValue('invalid-format');
    +			(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
    +				header: mockHeader,
    +				rawBody: Buffer.from(rawBody),
    +			});
    +
    +			const result = await verifySignature.call(mockWebhookFunctions);
    +
    +			expect(result).toBe(false);
    +		});
    +
    +		it('should return false when timestamp is missing', async () => {
    +			const timestamp = getCurrentTimestamp();
    +			const signature = createHmac('sha256', webhookSecret)
    +				.update(`${timestamp}.${rawBody}`)
    +				.digest('hex');
    +			const mockHeader = jest.fn().mockReturnValue(`v1=${signature}`);
    +			(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
    +				header: mockHeader,
    +				rawBody: Buffer.from(rawBody),
    +			});
    +
    +			const result = await verifySignature.call(mockWebhookFunctions);
    +
    +			expect(result).toBe(false);
    +		});
    +
    +		it('should return false when v1 signature is missing', async () => {
    +			const timestamp = getCurrentTimestamp();
    +			const mockHeader = jest.fn().mockReturnValue(`t=${timestamp}`);
    +			(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
    +				header: mockHeader,
    +				rawBody: Buffer.from(rawBody),
    +			});
    +
    +			const result = await verifySignature.call(mockWebhookFunctions);
    +
    +			expect(result).toBe(false);
    +		});
    +
    +		it('should return true when signature is valid', async () => {
    +			const timestamp = getCurrentTimestamp();
    +			const validSignature = generateValidSignature(timestamp, rawBody, webhookSecret);
    +			const mockHeader = jest.fn().mockReturnValue(validSignature);
    +			(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
    +				header: mockHeader,
    +				rawBody: Buffer.from(rawBody),
    +			});
    +
    +			const result = await verifySignature.call(mockWebhookFunctions);
    +
    +			expect(result).toBe(true);
    +		});
    +
    +		it('should return false when signature is invalid', async () => {
    +			const timestamp = getCurrentTimestamp();
    +			const wrongSecret = 'wrong_secret';
    +			const invalidSignature = generateValidSignature(timestamp, rawBody, wrongSecret);
    +			const mockHeader = jest.fn().mockReturnValue(invalidSignature);
    +			(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
    +				header: mockHeader,
    +				rawBody: Buffer.from(rawBody),
    +			});
    +
    +			const result = await verifySignature.call(mockWebhookFunctions);
    +
    +			expect(result).toBe(false);
    +		});
    +
    +		it('should handle complex signature header with multiple elements', async () => {
    +			const timestamp = getCurrentTimestamp();
    +			const signature = createHmac('sha256', webhookSecret)
    +				.update(`${timestamp}.${rawBody}`)
    +				.digest('hex');
    +			const complexHeader = `t=${timestamp},v1=${signature},v0=old_signature`;
    +			const mockHeader = jest.fn().mockReturnValue(complexHeader);
    +			(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
    +				header: mockHeader,
    +				rawBody: Buffer.from(rawBody),
    +			});
    +
    +			const result = await verifySignature.call(mockWebhookFunctions);
    +
    +			expect(result).toBe(true);
    +		});
    +
    +		it('should handle string rawBody', async () => {
    +			const timestamp = getCurrentTimestamp();
    +			const validSignature = generateValidSignature(timestamp, rawBody, webhookSecret);
    +			const mockHeader = jest.fn().mockReturnValue(validSignature);
    +			(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
    +				header: mockHeader,
    +				rawBody, // String instead of Buffer
    +			});
    +
    +			const result = await verifySignature.call(mockWebhookFunctions);
    +
    +			expect(result).toBe(true);
    +		});
    +
    +		it('should return false when rawBody is missing', async () => {
    +			const timestamp = getCurrentTimestamp();
    +			const validSignature = generateValidSignature(timestamp, rawBody, webhookSecret);
    +			const mockHeader = jest.fn().mockReturnValue(validSignature);
    +			(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
    +				header: mockHeader,
    +				rawBody: null,
    +			});
    +
    +			const result = await verifySignature.call(mockWebhookFunctions);
    +
    +			expect(result).toBe(false);
    +		});
    +
    +		it('should return false when signatureSecret is not a string', async () => {
    +			const timestamp = getCurrentTimestamp();
    +			const validSignature = generateValidSignature(timestamp, rawBody, webhookSecret);
    +			const mockHeader = jest.fn().mockReturnValue(validSignature);
    +			(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
    +				header: mockHeader,
    +				rawBody: Buffer.from(rawBody),
    +			});
    +			(mockWebhookFunctions.getCredentials as jest.Mock).mockResolvedValue({
    +				secretKey: 'sk_test_123',
    +				signatureSecret: 123, // Not a string
    +			});
    +
    +			const result = await verifySignature.call(mockWebhookFunctions);
    +
    +			expect(result).toBe(false);
    +		});
    +
    +		it('should return false when timestamp is older than 5 minutes', async () => {
    +			// Create timestamp that's 6 minutes (360 seconds) old
    +			const oldTimestamp = (Math.floor(Date.now() / 1000) - 360).toString();
    +			const validSignature = generateValidSignature(oldTimestamp, rawBody, webhookSecret);
    +			const mockHeader = jest.fn().mockReturnValue(validSignature);
    +			(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
    +				header: mockHeader,
    +				rawBody: Buffer.from(rawBody),
    +			});
    +
    +			const result = await verifySignature.call(mockWebhookFunctions);
    +
    +			expect(result).toBe(false);
    +		});
    +
    +		it('should return false when timestamp is from the future beyond tolerance', async () => {
    +			// Create timestamp that's 6 minutes (360 seconds) in the future
    +			const futureTimestamp = (Math.floor(Date.now() / 1000) + 360).toString();
    +			const validSignature = generateValidSignature(futureTimestamp, rawBody, webhookSecret);
    +			const mockHeader = jest.fn().mockReturnValue(validSignature);
    +			(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
    +				header: mockHeader,
    +				rawBody: Buffer.from(rawBody),
    +			});
    +
    +			const result = await verifySignature.call(mockWebhookFunctions);
    +
    +			expect(result).toBe(false);
    +		});
    +
    +		it('should return true when timestamp is within tolerance', async () => {
    +			// Create timestamp that's 4 minutes (240 seconds) old - within 5 minute tolerance
    +			const recentTimestamp = (Math.floor(Date.now() / 1000) - 240).toString();
    +			const validSignature = generateValidSignature(recentTimestamp, rawBody, webhookSecret);
    +			const mockHeader = jest.fn().mockReturnValue(validSignature);
    +			(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
    +				header: mockHeader,
    +				rawBody: Buffer.from(rawBody),
    +			});
    +
    +			const result = await verifySignature.call(mockWebhookFunctions);
    +
    +			expect(result).toBe(true);
    +		});
    +	});
    +});
    
  • packages/nodes-base/nodes/Stripe/__tests__/StripeTrigger.node.test.ts+79 1 modified
    @@ -1,13 +1,19 @@
    -import type { IHookFunctions } from 'n8n-workflow';
    +import type { IHookFunctions, IWebhookFunctions } from 'n8n-workflow';
     
     import { stripeApiRequest } from '../helpers';
     import { StripeTrigger } from '../StripeTrigger.node';
    +import { verifySignature } from '../StripeTriggerHelpers';
     
     jest.mock('../helpers', () => ({
     	stripeApiRequest: jest.fn(),
     }));
     
    +jest.mock('../StripeTriggerHelpers', () => ({
    +	verifySignature: jest.fn().mockResolvedValue(true),
    +}));
    +
     const mockedStripeApiRequest = jest.mocked(stripeApiRequest);
    +const mockedVerifySignature = jest.mocked(verifySignature);
     
     describe('Stripe Trigger Node', () => {
     	let node: StripeTrigger;
    @@ -96,4 +102,76 @@ describe('Stripe Trigger Node', () => {
     		const requestBody = callArgs[2];
     		expect(requestBody).toHaveProperty('api_version', '2025-05-28.basil');
     	});
    +
    +	describe('webhook signature verification', () => {
    +		let mockWebhookFunctions: IWebhookFunctions;
    +		const testBody = { type: 'charge.succeeded', id: 'ch_123' };
    +		const rawBody = JSON.stringify(testBody);
    +
    +		beforeEach(() => {
    +			mockWebhookFunctions = {
    +				getBodyData: jest.fn().mockReturnValue(testBody),
    +				getRequestObject: jest.fn().mockReturnValue({
    +					rawBody: Buffer.from(rawBody),
    +					body: testBody,
    +				}),
    +				getResponseObject: jest.fn().mockReturnValue({
    +					status: jest.fn().mockReturnThis(),
    +					send: jest.fn().mockReturnThis(),
    +					end: jest.fn(),
    +				}),
    +				getNodeParameter: jest.fn().mockReturnValue(['*']),
    +				helpers: {
    +					returnJsonArray: jest.fn().mockImplementation((data) => [data]),
    +				},
    +			} as unknown as IWebhookFunctions;
    +
    +			// Reset the verifySignature mock to return true by default
    +			mockedVerifySignature.mockResolvedValue(true);
    +		});
    +
    +		it('should process webhook with valid signature', async () => {
    +			mockedVerifySignature.mockResolvedValue(true);
    +
    +			const result = await node.webhook.call(mockWebhookFunctions);
    +
    +			expect(result).toEqual({
    +				workflowData: [[testBody]],
    +			});
    +			expect(mockedVerifySignature).toHaveBeenCalledWith();
    +		});
    +
    +		it('should reject webhook with invalid signature', async () => {
    +			mockedVerifySignature.mockResolvedValue(false);
    +
    +			const result = await node.webhook.call(mockWebhookFunctions);
    +
    +			expect(result).toEqual({
    +				noWebhookResponse: true,
    +			});
    +			expect(mockedVerifySignature).toHaveBeenCalledWith();
    +		});
    +
    +		it('should handle events filtering correctly', async () => {
    +			mockedVerifySignature.mockResolvedValue(true);
    +			(mockWebhookFunctions.getNodeParameter as jest.Mock).mockReturnValue([
    +				'payment_intent.succeeded',
    +			]);
    +
    +			const result = await node.webhook.call(mockWebhookFunctions);
    +
    +			expect(result).toEqual({});
    +		});
    +
    +		it('should process webhook when event type matches filter', async () => {
    +			mockedVerifySignature.mockResolvedValue(true);
    +			(mockWebhookFunctions.getNodeParameter as jest.Mock).mockReturnValue(['charge.succeeded']);
    +
    +			const result = await node.webhook.call(mockWebhookFunctions);
    +
    +			expect(result).toEqual({
    +				workflowData: [[testBody]],
    +			});
    +		});
    +	});
     });
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.