Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chubby-tires-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Refactor webhook verification to use verification from the `standardwebhooks` package, which is what our underlying provider relies on.
1 change: 1 addition & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
"@clerk/types": "workspace:^",
"cookie": "1.0.2",
"snakecase-keys": "8.0.1",
"standardwebhooks": "^1.0.0",
"tslib": "catalog:repo"
},
"devDependencies": {
Expand Down
147 changes: 131 additions & 16 deletions packages/backend/src/__tests__/webhooks.test.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,45 @@
import { Webhook } from 'standardwebhooks';
import { beforeEach, describe, expect, it } from 'vitest';

import { verifyWebhook } from '../webhooks';

describe('verifyWebhook', () => {
const mockSecret = 'test_signing_secret';
const mockSecret = 'whsec_' + Buffer.from('test_signing_secret_32_chars_long').toString('base64');
const mockBody = JSON.stringify({ type: 'user.created', data: { id: 'user_123' } });

beforeEach(() => {
process.env.CLERK_WEBHOOK_SIGNING_SECRET = mockSecret;
});

// Helper function to create a valid signature with Standard Webhooks
const createValidSignature = (id: string, timestamp: string, body: string) => {
const webhook = new Webhook(mockSecret);
// Create a signature using the Standard Webhooks library
return webhook.sign(id, new Date(parseInt(timestamp) * 1000), body);
};

it('throws when required headers are missing', async () => {
const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
// Missing svix-signature but with valid format for others
'svix-id': 'msg_123',
'svix-timestamp': '1614265330',
// Missing all required headers
}),
});

await expect(verifyWebhook(mockRequest)).rejects.toThrow('Missing required Svix headers: svix-signature');
await expect(verifyWebhook(mockRequest)).rejects.toThrow('Missing required webhook headers');
});

it('throws with all missing headers in error message', async () => {
const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({}),
headers: new Headers({
// Missing all required headers
}),
});

await expect(verifyWebhook(mockRequest)).rejects.toThrow(
'Missing required Svix headers: svix-id, svix-timestamp, svix-signature',
);
await expect(verifyWebhook(mockRequest)).rejects.toThrow('svix-id, svix-timestamp, svix-signature');
});

it('throws when signing secret is missing', async () => {
Expand All @@ -44,24 +50,26 @@ describe('verifyWebhook', () => {
body: mockBody,
headers: new Headers({
'svix-id': 'msg_123',
'svix-timestamp': '1614265330',
'svix-timestamp': (Date.now() / 1000).toString(),
'svix-signature': 'v1,test_signature',
}),
});

await expect(verifyWebhook(mockRequest)).rejects.toThrow(
'Missing webhook signing secret. Set the CLERK_WEBHOOK_SIGNING_SECRET environment variable with the webhook secret from the Clerk Dashboard.',
);
await expect(verifyWebhook(mockRequest)).rejects.toThrow('Missing webhook signing secret');
});

it('validates webhook request requirements', async () => {
const svixId = 'msg_123';
const svixTimestamp = (Date.now() / 1000).toString();
const validSignature = createValidSignature(svixId, svixTimestamp, mockBody);

const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': 'msg_123',
'svix-timestamp': '1614265330',
'svix-signature': 'v1,test_signature',
'svix-id': svixId,
'svix-timestamp': svixTimestamp,
'svix-signature': validSignature,
}),
});

Expand All @@ -72,4 +80,111 @@ describe('verifyWebhook', () => {
expect(result).toHaveProperty('type', 'user.created');
expect(result).toHaveProperty('data.id', 'user_123');
});

it('should accept valid signatures', async () => {
const svixId = 'msg_123';
const svixTimestamp = (Date.now() / 1000).toString();
const validSignature = createValidSignature(svixId, svixTimestamp, mockBody);

const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': svixId,
'svix-timestamp': svixTimestamp,
'svix-signature': validSignature,
}),
});

// Should accept and return parsed data
const result = await verifyWebhook(mockRequest);
expect(result).toHaveProperty('type', 'user.created');
expect(result).toHaveProperty('data.id', 'user_123');
});

it('should reject invalid signatures', async () => {
const svixId = 'msg_123';
const svixTimestamp = (Date.now() / 1000).toString();
const invalidSignature = 'v1,invalid_signature_here';

const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': svixId,
'svix-timestamp': svixTimestamp,
'svix-signature': invalidSignature,
}),
});

// Should reject invalid signatures
await expect(verifyWebhook(mockRequest)).rejects.toThrow('No matching signature found');
});

it('should handle multiple signatures in header', async () => {
const svixId = 'msg_123';
const svixTimestamp = (Date.now() / 1000).toString();
const validSignature = createValidSignature(svixId, svixTimestamp, mockBody);
const invalidSignature = 'v1,invalid_signature';

const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': svixId,
'svix-timestamp': svixTimestamp,
'svix-signature': `${invalidSignature} ${validSignature}`,
}),
});

// Should accept if any signature in the list is valid
const result = await verifyWebhook(mockRequest);
expect(result).toHaveProperty('type', 'user.created');
expect(result).toHaveProperty('data.id', 'user_123');
});

it('should handle signatures without version prefixes for backward compatibility', async () => {
const svixId = 'msg_123';
const svixTimestamp = (Date.now() / 1000).toString();
// Test with Standard Webhooks generated signature without custom prefix
const validSignature = createValidSignature(svixId, svixTimestamp, mockBody);

const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': svixId,
'svix-timestamp': svixTimestamp,
'svix-signature': validSignature,
}),
});

// Should accept signatures without version prefixes
const result = await verifyWebhook(mockRequest);
expect(result).toHaveProperty('type', 'user.created');
expect(result).toHaveProperty('data.id', 'user_123');
});

it('should verify against Standard Webhooks specification', async () => {
// Test with proper Clerk webhook format
const clerkPayload = '{"type":"user.created","data":{"id":"user_123","email":"[email protected]"}}';
const msgId = 'msg_test123';
const timestamp = (Date.now() / 1000).toString();

const validSignature = createValidSignature(msgId, timestamp, clerkPayload);

const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: clerkPayload,
headers: new Headers({
'svix-id': msgId,
'svix-timestamp': timestamp,
'svix-signature': validSignature,
}),
});

const result = await verifyWebhook(mockRequest, { signingSecret: mockSecret });
expect(result).toHaveProperty('type', 'user.created');
expect(result).toHaveProperty('data.id', 'user_123');
});
});
79 changes: 56 additions & 23 deletions packages/backend/src/webhooks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getEnvVariable } from '@clerk/shared/getEnvVariable';
import crypto from 'crypto';
import { errorThrower } from 'src/util/shared';
import { Webhook } from 'standardwebhooks';

import type { WebhookEvent } from './api/resources/Webhooks';

Expand All @@ -14,16 +14,46 @@ export type VerifyWebhookOptions = {
signingSecret?: string;
};

// Standard Webhooks header names
const STANDARD_WEBHOOK_ID_HEADER = 'webhook-id';
const STANDARD_WEBHOOK_TIMESTAMP_HEADER = 'webhook-timestamp';
const STANDARD_WEBHOOK_SIGNATURE_HEADER = 'webhook-signature';

// Svix header names (for mapping)
const SVIX_ID_HEADER = 'svix-id';
const SVIX_TIMESTAMP_HEADER = 'svix-timestamp';
const SVIX_SIGNATURE_HEADER = 'svix-signature';

const REQUIRED_SVIX_HEADERS = [SVIX_ID_HEADER, SVIX_TIMESTAMP_HEADER, SVIX_SIGNATURE_HEADER] as const;
const REQUIRED_WEBHOOK_HEADERS = [SVIX_ID_HEADER, SVIX_TIMESTAMP_HEADER, SVIX_SIGNATURE_HEADER] as const;

export * from './api/resources/Webhooks';

/**
* Verifies the authenticity of a webhook request using Svix. Returns a promise that resolves to the verified webhook event data.
* Maps Svix headers to Standard Webhooks headers for compatibility
*/
function createStandardWebhookHeaders(request: Request): Record<string, string> {
const headers: Record<string, string> = {};

// Map Svix headers to Standard Webhooks headers
const svixId = request.headers.get(SVIX_ID_HEADER);
const svixTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER);
const svixSignature = request.headers.get(SVIX_SIGNATURE_HEADER);

if (svixId) {
headers[STANDARD_WEBHOOK_ID_HEADER] = svixId;
}
if (svixTimestamp) {
headers[STANDARD_WEBHOOK_TIMESTAMP_HEADER] = svixTimestamp;
}
if (svixSignature) {
headers[STANDARD_WEBHOOK_SIGNATURE_HEADER] = svixSignature;
}

return headers;
}

/**
* Verifies the authenticity of a webhook request using Standard Webhooks. Returns a promise that resolves to the verified webhook event data.
*
* @param request - The request object.
* @param options - Optional configuration object.
Expand Down Expand Up @@ -56,39 +86,42 @@ export * from './api/resources/Webhooks';
*/
export async function verifyWebhook(request: Request, options: VerifyWebhookOptions = {}): Promise<WebhookEvent> {
const secret = options.signingSecret ?? getEnvVariable('CLERK_WEBHOOK_SIGNING_SECRET');
const svixId = request.headers.get(SVIX_ID_HEADER);
const svixTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER);
const svixSignature = request.headers.get(SVIX_SIGNATURE_HEADER);

if (!secret) {
return errorThrower.throw(
'Missing webhook signing secret. Set the CLERK_WEBHOOK_SIGNING_SECRET environment variable with the webhook secret from the Clerk Dashboard.',
);
}

if (!svixId || !svixTimestamp || !svixSignature) {
const missingHeaders = REQUIRED_SVIX_HEADERS.filter(header => !request.headers.has(header));
return errorThrower.throw(`Missing required Svix headers: ${missingHeaders.join(', ')}`);
// Check for required Svix headers
const webhookId = request.headers.get(SVIX_ID_HEADER);
const webhookTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER);
const webhookSignature = request.headers.get(SVIX_SIGNATURE_HEADER);

if (!webhookId || !webhookTimestamp || !webhookSignature) {
const missingHeaders = REQUIRED_WEBHOOK_HEADERS.filter(header => !request.headers.has(header));
return errorThrower.throw(`Missing required webhook headers: ${missingHeaders.join(', ')}`);
}

const body = await request.text();

const signedContent = `${svixId}.${svixTimestamp}.${body}`;
// Create Standard Webhooks compatible headers mapping
const standardHeaders = createStandardWebhookHeaders(request);

const secretBytes = Buffer.from(secret.split('_')[1], 'base64');
// Initialize Standard Webhooks verifier
const webhook = new Webhook(secret);

const constructedSignature = crypto.createHmac('sha256', secretBytes).update(signedContent).digest('base64');
try {
// Verify using Standard Webhooks - this provides constant-time comparison
// and proper signature format handling
const payload = webhook.verify(body, standardHeaders) as Record<string, unknown>;

// svixSignature can be a string with one or more space separated signatures
if (svixSignature.split(' ').includes(constructedSignature)) {
return errorThrower.throw('Incoming webhook does not have a valid signature');
return {
type: payload.type,
object: 'event',
data: payload.data,
} as WebhookEvent;
} catch (e) {
return errorThrower.throw(`Unable to verify incoming webhook: ${e instanceof Error ? e.message : 'Unknown error'}`);
}

const payload = JSON.parse(body);

return {
type: payload.type,
object: 'event',
data: payload.data,
} as WebhookEvent;
}
Loading
Loading