Merged
Show file tree
Hide file tree
Changes from all commits
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
177 changes: 161 additions & 16 deletions packages/backend/src/__tests__/webhooks.test.ts
Original file line numberDiff line numberDiff 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');
Comment thread
brkalow marked this conversation as resolved.
});

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,141 @@ 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":"test@example.com"}}';
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');
});

it('should handle whitespace-only header values correctly', async () => {
const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': '', // Empty - should be caught
'svix-timestamp': ' ', // Whitespace - should be caught
'svix-signature': 'v1,signature',
}),
});

// This should fail because whitespace-only headers should be treated as missing
await expect(verifyWebhook(mockRequest)).rejects.toThrow('Missing required webhook headers');
});

it('should handle mixed empty and whitespace headers correctly', async () => {
const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': ' \t ', // Mixed whitespace and tabs
'svix-timestamp': '\n', // Newline character
'svix-signature': '', // Empty string
}),
});

// All should be treated as missing
await expect(verifyWebhook(mockRequest)).rejects.toThrow('svix-id, svix-timestamp, svix-signature');
});
});
90 changes: 66 additions & 24 deletions packages/backend/src/webhooks.ts
Original file line numberDiff line numberDiff 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,44 @@ 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;

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)?.trim();
const svixTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER)?.trim();
const svixSignature = request.headers.get(SVIX_SIGNATURE_HEADER)?.trim();

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 DownExpand Up@@ -56,39 +84,53 @@ 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)?.trim();
const webhookTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER)?.trim();
const webhookSignature = request.headers.get(SVIX_SIGNATURE_HEADER)?.trim();

if (!webhookId || !webhookTimestamp || !webhookSignature) {
const missingHeaders = [];

if (!webhookId) {
missingHeaders.push(SVIX_ID_HEADER);
}
if (!webhookTimestamp) {
missingHeaders.push(SVIX_TIMESTAMP_HEADER);
}
if (!webhookSignature) {
missingHeaders.push(SVIX_SIGNATURE_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'}`);
}
Comment thread
brkalow marked this conversation as resolved.

const payload = JSON.parse(body);

return {
type: payload.type,
object: 'event',
data: payload.data,
} as WebhookEvent;
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
177 changes: 161 additions & 16 deletions packages/backend/src/__tests__/webhooks.test.ts
Original file line numberDiff line numberDiff 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');
Comment thread
brkalow marked this conversation as resolved.
});

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,141 @@ 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":"test@example.com"}}';
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');
});

it('should handle whitespace-only header values correctly', async () => {
const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': '', // Empty - should be caught
'svix-timestamp': ' ', // Whitespace - should be caught
'svix-signature': 'v1,signature',
}),
});

// This should fail because whitespace-only headers should be treated as missing
await expect(verifyWebhook(mockRequest)).rejects.toThrow('Missing required webhook headers');
});

it('should handle mixed empty and whitespace headers correctly', async () => {
const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': ' \t ', // Mixed whitespace and tabs
'svix-timestamp': '\n', // Newline character
'svix-signature': '', // Empty string
}),
});

// All should be treated as missing
await expect(verifyWebhook(mockRequest)).rejects.toThrow('svix-id, svix-timestamp, svix-signature');
});
});
90 changes: 66 additions & 24 deletions packages/backend/src/webhooks.ts
Original file line numberDiff line numberDiff 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,44 @@ 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;

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)?.trim();
const svixTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER)?.trim();
const svixSignature = request.headers.get(SVIX_SIGNATURE_HEADER)?.trim();

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 DownExpand Up@@ -56,39 +84,53 @@ 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)?.trim();
const webhookTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER)?.trim();
const webhookSignature = request.headers.get(SVIX_SIGNATURE_HEADER)?.trim();

if (!webhookId || !webhookTimestamp || !webhookSignature) {
const missingHeaders = [];

if (!webhookId) {
missingHeaders.push(SVIX_ID_HEADER);
}
if (!webhookTimestamp) {
missingHeaders.push(SVIX_TIMESTAMP_HEADER);
}
if (!webhookSignature) {
missingHeaders.push(SVIX_SIGNATURE_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'}`);
}
Comment thread
brkalow marked this conversation as resolved.

const payload = JSON.parse(body);

return {
type: payload.type,
object: 'event',
data: payload.data,
} as WebhookEvent;
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
177 changes: 161 additions & 16 deletions packages/backend/src/__tests__/webhooks.test.ts
Original file line numberDiff line numberDiff 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');
Comment thread
brkalow marked this conversation as resolved.
});

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,141 @@ 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":"test@example.com"}}';
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');
});

it('should handle whitespace-only header values correctly', async () => {
const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': '', // Empty - should be caught
'svix-timestamp': ' ', // Whitespace - should be caught
'svix-signature': 'v1,signature',
}),
});

// This should fail because whitespace-only headers should be treated as missing
await expect(verifyWebhook(mockRequest)).rejects.toThrow('Missing required webhook headers');
});

it('should handle mixed empty and whitespace headers correctly', async () => {
const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': ' \t ', // Mixed whitespace and tabs
'svix-timestamp': '\n', // Newline character
'svix-signature': '', // Empty string
}),
});

// All should be treated as missing
await expect(verifyWebhook(mockRequest)).rejects.toThrow('svix-id, svix-timestamp, svix-signature');
});
});
90 changes: 66 additions & 24 deletions packages/backend/src/webhooks.ts
Original file line numberDiff line numberDiff 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,44 @@ 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;

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)?.trim();
const svixTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER)?.trim();
const svixSignature = request.headers.get(SVIX_SIGNATURE_HEADER)?.trim();

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 DownExpand Up@@ -56,39 +84,53 @@ 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)?.trim();
const webhookTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER)?.trim();
const webhookSignature = request.headers.get(SVIX_SIGNATURE_HEADER)?.trim();

if (!webhookId || !webhookTimestamp || !webhookSignature) {
const missingHeaders = [];

if (!webhookId) {
missingHeaders.push(SVIX_ID_HEADER);
}
if (!webhookTimestamp) {
missingHeaders.push(SVIX_TIMESTAMP_HEADER);
}
if (!webhookSignature) {
missingHeaders.push(SVIX_SIGNATURE_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'}`);
}
Comment thread
brkalow marked this conversation as resolved.

const payload = JSON.parse(body);

return {
type: payload.type,
object: 'event',
data: payload.data,
} as WebhookEvent;
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
177 changes: 161 additions & 16 deletions packages/backend/src/__tests__/webhooks.test.ts
Original file line numberDiff line numberDiff 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');
Comment thread
brkalow marked this conversation as resolved.
});

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,141 @@ 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":"test@example.com"}}';
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');
});

it('should handle whitespace-only header values correctly', async () => {
const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': '', // Empty - should be caught
'svix-timestamp': ' ', // Whitespace - should be caught
'svix-signature': 'v1,signature',
}),
});

// This should fail because whitespace-only headers should be treated as missing
await expect(verifyWebhook(mockRequest)).rejects.toThrow('Missing required webhook headers');
});

it('should handle mixed empty and whitespace headers correctly', async () => {
const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': ' \t ', // Mixed whitespace and tabs
'svix-timestamp': '\n', // Newline character
'svix-signature': '', // Empty string
}),
});

// All should be treated as missing
await expect(verifyWebhook(mockRequest)).rejects.toThrow('svix-id, svix-timestamp, svix-signature');
});
});
90 changes: 66 additions & 24 deletions packages/backend/src/webhooks.ts
Original file line numberDiff line numberDiff 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,44 @@ 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;

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)?.trim();
const svixTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER)?.trim();
const svixSignature = request.headers.get(SVIX_SIGNATURE_HEADER)?.trim();

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 DownExpand Up@@ -56,39 +84,53 @@ 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)?.trim();
const webhookTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER)?.trim();
const webhookSignature = request.headers.get(SVIX_SIGNATURE_HEADER)?.trim();

if (!webhookId || !webhookTimestamp || !webhookSignature) {
const missingHeaders = [];

if (!webhookId) {
missingHeaders.push(SVIX_ID_HEADER);
}
if (!webhookTimestamp) {
missingHeaders.push(SVIX_TIMESTAMP_HEADER);
}
if (!webhookSignature) {
missingHeaders.push(SVIX_SIGNATURE_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'}`);
}
Comment thread
brkalow marked this conversation as resolved.

const payload = JSON.parse(body);

return {
type: payload.type,
object: 'event',
data: payload.data,
} as WebhookEvent;
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
177 changes: 161 additions & 16 deletions packages/backend/src/__tests__/webhooks.test.ts
Original file line numberDiff line numberDiff 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');
Comment thread
brkalow marked this conversation as resolved.
});

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,141 @@ 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":"test@example.com"}}';
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');
});

it('should handle whitespace-only header values correctly', async () => {
const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': '', // Empty - should be caught
'svix-timestamp': ' ', // Whitespace - should be caught
'svix-signature': 'v1,signature',
}),
});

// This should fail because whitespace-only headers should be treated as missing
await expect(verifyWebhook(mockRequest)).rejects.toThrow('Missing required webhook headers');
});

it('should handle mixed empty and whitespace headers correctly', async () => {
const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': ' \t ', // Mixed whitespace and tabs
'svix-timestamp': '\n', // Newline character
'svix-signature': '', // Empty string
}),
});

// All should be treated as missing
await expect(verifyWebhook(mockRequest)).rejects.toThrow('svix-id, svix-timestamp, svix-signature');
});
});
90 changes: 66 additions & 24 deletions packages/backend/src/webhooks.ts
Original file line numberDiff line numberDiff 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,44 @@ 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;

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)?.trim();
const svixTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER)?.trim();
const svixSignature = request.headers.get(SVIX_SIGNATURE_HEADER)?.trim();

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 DownExpand Up@@ -56,39 +84,53 @@ 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)?.trim();
const webhookTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER)?.trim();
const webhookSignature = request.headers.get(SVIX_SIGNATURE_HEADER)?.trim();

if (!webhookId || !webhookTimestamp || !webhookSignature) {
const missingHeaders = [];

if (!webhookId) {
missingHeaders.push(SVIX_ID_HEADER);
}
if (!webhookTimestamp) {
missingHeaders.push(SVIX_TIMESTAMP_HEADER);
}
if (!webhookSignature) {
missingHeaders.push(SVIX_SIGNATURE_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'}`);
}
Comment thread
brkalow marked this conversation as resolved.

const payload = JSON.parse(body);

return {
type: payload.type,
object: 'event',
data: payload.data,
} as WebhookEvent;
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
177 changes: 161 additions & 16 deletions packages/backend/src/__tests__/webhooks.test.ts
Original file line numberDiff line numberDiff 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');
Comment thread
brkalow marked this conversation as resolved.
});

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,141 @@ 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":"test@example.com"}}';
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');
});

it('should handle whitespace-only header values correctly', async () => {
const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': '', // Empty - should be caught
'svix-timestamp': ' ', // Whitespace - should be caught
'svix-signature': 'v1,signature',
}),
});

// This should fail because whitespace-only headers should be treated as missing
await expect(verifyWebhook(mockRequest)).rejects.toThrow('Missing required webhook headers');
});

it('should handle mixed empty and whitespace headers correctly', async () => {
const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': ' \t ', // Mixed whitespace and tabs
'svix-timestamp': '\n', // Newline character
'svix-signature': '', // Empty string
}),
});

// All should be treated as missing
await expect(verifyWebhook(mockRequest)).rejects.toThrow('svix-id, svix-timestamp, svix-signature');
});
});
90 changes: 66 additions & 24 deletions packages/backend/src/webhooks.ts
Original file line numberDiff line numberDiff 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,44 @@ 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;

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)?.trim();
const svixTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER)?.trim();
const svixSignature = request.headers.get(SVIX_SIGNATURE_HEADER)?.trim();

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 DownExpand Up@@ -56,39 +84,53 @@ 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)?.trim();
const webhookTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER)?.trim();
const webhookSignature = request.headers.get(SVIX_SIGNATURE_HEADER)?.trim();

if (!webhookId || !webhookTimestamp || !webhookSignature) {
const missingHeaders = [];

if (!webhookId) {
missingHeaders.push(SVIX_ID_HEADER);
}
if (!webhookTimestamp) {
missingHeaders.push(SVIX_TIMESTAMP_HEADER);
}
if (!webhookSignature) {
missingHeaders.push(SVIX_SIGNATURE_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'}`);
}
Comment thread
brkalow marked this conversation as resolved.

const payload = JSON.parse(body);

return {
type: payload.type,
object: 'event',
data: payload.data,
} as WebhookEvent;
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
177 changes: 161 additions & 16 deletions packages/backend/src/__tests__/webhooks.test.ts
Original file line numberDiff line numberDiff 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');
Comment thread
brkalow marked this conversation as resolved.
});

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,141 @@ 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":"test@example.com"}}';
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');
});

it('should handle whitespace-only header values correctly', async () => {
const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': '', // Empty - should be caught
'svix-timestamp': ' ', // Whitespace - should be caught
'svix-signature': 'v1,signature',
}),
});

// This should fail because whitespace-only headers should be treated as missing
await expect(verifyWebhook(mockRequest)).rejects.toThrow('Missing required webhook headers');
});

it('should handle mixed empty and whitespace headers correctly', async () => {
const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': ' \t ', // Mixed whitespace and tabs
'svix-timestamp': '\n', // Newline character
'svix-signature': '', // Empty string
}),
});

// All should be treated as missing
await expect(verifyWebhook(mockRequest)).rejects.toThrow('svix-id, svix-timestamp, svix-signature');
});
});
90 changes: 66 additions & 24 deletions packages/backend/src/webhooks.ts
Original file line numberDiff line numberDiff 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,44 @@ 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;

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)?.trim();
const svixTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER)?.trim();
const svixSignature = request.headers.get(SVIX_SIGNATURE_HEADER)?.trim();

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 DownExpand Up@@ -56,39 +84,53 @@ 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)?.trim();
const webhookTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER)?.trim();
const webhookSignature = request.headers.get(SVIX_SIGNATURE_HEADER)?.trim();

if (!webhookId || !webhookTimestamp || !webhookSignature) {
const missingHeaders = [];

if (!webhookId) {
missingHeaders.push(SVIX_ID_HEADER);
}
if (!webhookTimestamp) {
missingHeaders.push(SVIX_TIMESTAMP_HEADER);
}
if (!webhookSignature) {
missingHeaders.push(SVIX_SIGNATURE_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'}`);
}
Comment thread
brkalow marked this conversation as resolved.

const payload = JSON.parse(body);

return {
type: payload.type,
object: 'event',
data: payload.data,
} as WebhookEvent;
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
177 changes: 161 additions & 16 deletions packages/backend/src/__tests__/webhooks.test.ts
Original file line numberDiff line numberDiff 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');
Comment thread
brkalow marked this conversation as resolved.
});

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,141 @@ 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":"test@example.com"}}';
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');
});

it('should handle whitespace-only header values correctly', async () => {
const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': '', // Empty - should be caught
'svix-timestamp': ' ', // Whitespace - should be caught
'svix-signature': 'v1,signature',
}),
});

// This should fail because whitespace-only headers should be treated as missing
await expect(verifyWebhook(mockRequest)).rejects.toThrow('Missing required webhook headers');
});

it('should handle mixed empty and whitespace headers correctly', async () => {
const mockRequest = new Request('https://clerk.com/webhooks', {
method: 'POST',
body: mockBody,
headers: new Headers({
'svix-id': ' \t ', // Mixed whitespace and tabs
'svix-timestamp': '\n', // Newline character
'svix-signature': '', // Empty string
}),
});

// All should be treated as missing
await expect(verifyWebhook(mockRequest)).rejects.toThrow('svix-id, svix-timestamp, svix-signature');
});
});
90 changes: 66 additions & 24 deletions packages/backend/src/webhooks.ts
Original file line numberDiff line numberDiff 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,44 @@ 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;

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)?.trim();
const svixTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER)?.trim();
const svixSignature = request.headers.get(SVIX_SIGNATURE_HEADER)?.trim();

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 DownExpand Up@@ -56,39 +84,53 @@ 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)?.trim();
const webhookTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER)?.trim();
const webhookSignature = request.headers.get(SVIX_SIGNATURE_HEADER)?.trim();

if (!webhookId || !webhookTimestamp || !webhookSignature) {
const missingHeaders = [];

if (!webhookId) {
missingHeaders.push(SVIX_ID_HEADER);
}
if (!webhookTimestamp) {
missingHeaders.push(SVIX_TIMESTAMP_HEADER);
}
if (!webhookSignature) {
missingHeaders.push(SVIX_SIGNATURE_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'}`);
}
Comment thread
brkalow marked this conversation as resolved.

const payload = JSON.parse(body);

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