Skip to content

Commit c2f24da

Browse files
authored
chore(backend): Refactor verifyWebhook() to use standardwebhooks library (#6252)
1 parent 6e795db commit c2f24da

File tree

5 files changed

+254
-40
lines changed

5 files changed

+254
-40
lines changed

.changeset/chubby-tires-end.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': patch
3+
---
4+
5+
Refactor webhook verification to use verification from the `standardwebhooks` package, which is what our underlying provider relies on.

packages/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@
111111
"@clerk/types": "workspace:^",
112112
"cookie": "1.0.2",
113113
"snakecase-keys": "8.0.1",
114+
"standardwebhooks": "^1.0.0",
114115
"tslib": "catalog:repo"
115116
},
116117
"devDependencies": {
Lines changed: 161 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,45 @@
1+
import { Webhook } from 'standardwebhooks';
12
import { beforeEach, describe, expect, it } from 'vitest';
23

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

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

910
beforeEach(() => {
1011
process.env.CLERK_WEBHOOK_SIGNING_SECRET = mockSecret;
1112
});
1213

14+
// Helper function to create a valid signature with Standard Webhooks
15+
const createValidSignature = (id: string, timestamp: string, body: string) => {
16+
const webhook = new Webhook(mockSecret);
17+
// Create a signature using the Standard Webhooks library
18+
return webhook.sign(id, new Date(parseInt(timestamp) * 1000), body);
19+
};
20+
1321
it('throws when required headers are missing', async () => {
1422
const mockRequest = new Request('https://clerk.com/webhooks', {
1523
method: 'POST',
1624
body: mockBody,
1725
headers: new Headers({
18-
// Missing svix-signature but with valid format for others
19-
'svix-id': 'msg_123',
20-
'svix-timestamp': '1614265330',
26+
// Missing all required headers
2127
}),
2228
});
2329

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

2733
it('throws with all missing headers in error message', async () => {
2834
const mockRequest = new Request('https://clerk.com/webhooks', {
2935
method: 'POST',
3036
body: mockBody,
31-
headers: new Headers({}),
37+
headers: new Headers({
38+
// Missing all required headers
39+
}),
3240
});
3341

34-
await expect(verifyWebhook(mockRequest)).rejects.toThrow(
35-
'Missing required Svix headers: svix-id, svix-timestamp, svix-signature',
36-
);
42+
await expect(verifyWebhook(mockRequest)).rejects.toThrow('svix-id, svix-timestamp, svix-signature');
3743
});
3844

3945
it('throws when signing secret is missing', async () => {
@@ -44,24 +50,26 @@ describe('verifyWebhook', () => {
4450
body: mockBody,
4551
headers: new Headers({
4652
'svix-id': 'msg_123',
47-
'svix-timestamp': '1614265330',
53+
'svix-timestamp': (Date.now() / 1000).toString(),
4854
'svix-signature': 'v1,test_signature',
4955
}),
5056
});
5157

52-
await expect(verifyWebhook(mockRequest)).rejects.toThrow(
53-
'Missing webhook signing secret. Set the CLERK_WEBHOOK_SIGNING_SECRET environment variable with the webhook secret from the Clerk Dashboard.',
54-
);
58+
await expect(verifyWebhook(mockRequest)).rejects.toThrow('Missing webhook signing secret');
5559
});
5660

5761
it('validates webhook request requirements', async () => {
62+
const svixId = 'msg_123';
63+
const svixTimestamp = (Date.now() / 1000).toString();
64+
const validSignature = createValidSignature(svixId, svixTimestamp, mockBody);
65+
5866
const mockRequest = new Request('https://clerk.com/webhooks', {
5967
method: 'POST',
6068
body: mockBody,
6169
headers: new Headers({
62-
'svix-id': 'msg_123',
63-
'svix-timestamp': '1614265330',
64-
'svix-signature': 'v1,test_signature',
70+
'svix-id': svixId,
71+
'svix-timestamp': svixTimestamp,
72+
'svix-signature': validSignature,
6573
}),
6674
});
6775

@@ -72,4 +80,141 @@ describe('verifyWebhook', () => {
7280
expect(result).toHaveProperty('type', 'user.created');
7381
expect(result).toHaveProperty('data.id', 'user_123');
7482
});
83+
84+
it('should accept valid signatures', async () => {
85+
const svixId = 'msg_123';
86+
const svixTimestamp = (Date.now() / 1000).toString();
87+
const validSignature = createValidSignature(svixId, svixTimestamp, mockBody);
88+
89+
const mockRequest = new Request('https://clerk.com/webhooks', {
90+
method: 'POST',
91+
body: mockBody,
92+
headers: new Headers({
93+
'svix-id': svixId,
94+
'svix-timestamp': svixTimestamp,
95+
'svix-signature': validSignature,
96+
}),
97+
});
98+
99+
// Should accept and return parsed data
100+
const result = await verifyWebhook(mockRequest);
101+
expect(result).toHaveProperty('type', 'user.created');
102+
expect(result).toHaveProperty('data.id', 'user_123');
103+
});
104+
105+
it('should reject invalid signatures', async () => {
106+
const svixId = 'msg_123';
107+
const svixTimestamp = (Date.now() / 1000).toString();
108+
const invalidSignature = 'v1,invalid_signature_here';
109+
110+
const mockRequest = new Request('https://clerk.com/webhooks', {
111+
method: 'POST',
112+
body: mockBody,
113+
headers: new Headers({
114+
'svix-id': svixId,
115+
'svix-timestamp': svixTimestamp,
116+
'svix-signature': invalidSignature,
117+
}),
118+
});
119+
120+
// Should reject invalid signatures
121+
await expect(verifyWebhook(mockRequest)).rejects.toThrow('No matching signature found');
122+
});
123+
124+
it('should handle multiple signatures in header', async () => {
125+
const svixId = 'msg_123';
126+
const svixTimestamp = (Date.now() / 1000).toString();
127+
const validSignature = createValidSignature(svixId, svixTimestamp, mockBody);
128+
const invalidSignature = 'v1,invalid_signature';
129+
130+
const mockRequest = new Request('https://clerk.com/webhooks', {
131+
method: 'POST',
132+
body: mockBody,
133+
headers: new Headers({
134+
'svix-id': svixId,
135+
'svix-timestamp': svixTimestamp,
136+
'svix-signature': `${invalidSignature} ${validSignature}`,
137+
}),
138+
});
139+
140+
// Should accept if any signature in the list is valid
141+
const result = await verifyWebhook(mockRequest);
142+
expect(result).toHaveProperty('type', 'user.created');
143+
expect(result).toHaveProperty('data.id', 'user_123');
144+
});
145+
146+
it('should handle signatures without version prefixes for backward compatibility', async () => {
147+
const svixId = 'msg_123';
148+
const svixTimestamp = (Date.now() / 1000).toString();
149+
// Test with Standard Webhooks generated signature without custom prefix
150+
const validSignature = createValidSignature(svixId, svixTimestamp, mockBody);
151+
152+
const mockRequest = new Request('https://clerk.com/webhooks', {
153+
method: 'POST',
154+
body: mockBody,
155+
headers: new Headers({
156+
'svix-id': svixId,
157+
'svix-timestamp': svixTimestamp,
158+
'svix-signature': validSignature,
159+
}),
160+
});
161+
162+
// Should accept signatures without version prefixes
163+
const result = await verifyWebhook(mockRequest);
164+
expect(result).toHaveProperty('type', 'user.created');
165+
expect(result).toHaveProperty('data.id', 'user_123');
166+
});
167+
168+
it('should verify against Standard Webhooks specification', async () => {
169+
// Test with proper Clerk webhook format
170+
const clerkPayload = '{"type":"user.created","data":{"id":"user_123","email":"[email protected]"}}';
171+
const msgId = 'msg_test123';
172+
const timestamp = (Date.now() / 1000).toString();
173+
174+
const validSignature = createValidSignature(msgId, timestamp, clerkPayload);
175+
176+
const mockRequest = new Request('https://clerk.com/webhooks', {
177+
method: 'POST',
178+
body: clerkPayload,
179+
headers: new Headers({
180+
'svix-id': msgId,
181+
'svix-timestamp': timestamp,
182+
'svix-signature': validSignature,
183+
}),
184+
});
185+
186+
const result = await verifyWebhook(mockRequest, { signingSecret: mockSecret });
187+
expect(result).toHaveProperty('type', 'user.created');
188+
expect(result).toHaveProperty('data.id', 'user_123');
189+
});
190+
191+
it('should handle whitespace-only header values correctly', async () => {
192+
const mockRequest = new Request('https://clerk.com/webhooks', {
193+
method: 'POST',
194+
body: mockBody,
195+
headers: new Headers({
196+
'svix-id': '', // Empty - should be caught
197+
'svix-timestamp': ' ', // Whitespace - should be caught
198+
'svix-signature': 'v1,signature',
199+
}),
200+
});
201+
202+
// This should fail because whitespace-only headers should be treated as missing
203+
await expect(verifyWebhook(mockRequest)).rejects.toThrow('Missing required webhook headers');
204+
});
205+
206+
it('should handle mixed empty and whitespace headers correctly', async () => {
207+
const mockRequest = new Request('https://clerk.com/webhooks', {
208+
method: 'POST',
209+
body: mockBody,
210+
headers: new Headers({
211+
'svix-id': ' \t ', // Mixed whitespace and tabs
212+
'svix-timestamp': '\n', // Newline character
213+
'svix-signature': '', // Empty string
214+
}),
215+
});
216+
217+
// All should be treated as missing
218+
await expect(verifyWebhook(mockRequest)).rejects.toThrow('svix-id, svix-timestamp, svix-signature');
219+
});
75220
});

packages/backend/src/webhooks.ts

Lines changed: 66 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getEnvVariable } from '@clerk/shared/getEnvVariable';
2-
import crypto from 'crypto';
32
import { errorThrower } from 'src/util/shared';
3+
import { Webhook } from 'standardwebhooks';
44

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

@@ -14,16 +14,44 @@ export type VerifyWebhookOptions = {
1414
signingSecret?: string;
1515
};
1616

17+
// Standard Webhooks header names
18+
const STANDARD_WEBHOOK_ID_HEADER = 'webhook-id';
19+
const STANDARD_WEBHOOK_TIMESTAMP_HEADER = 'webhook-timestamp';
20+
const STANDARD_WEBHOOK_SIGNATURE_HEADER = 'webhook-signature';
21+
22+
// Svix header names (for mapping)
1723
const SVIX_ID_HEADER = 'svix-id';
1824
const SVIX_TIMESTAMP_HEADER = 'svix-timestamp';
1925
const SVIX_SIGNATURE_HEADER = 'svix-signature';
2026

21-
const REQUIRED_SVIX_HEADERS = [SVIX_ID_HEADER, SVIX_TIMESTAMP_HEADER, SVIX_SIGNATURE_HEADER] as const;
22-
2327
export * from './api/resources/Webhooks';
2428

2529
/**
26-
* Verifies the authenticity of a webhook request using Svix. Returns a promise that resolves to the verified webhook event data.
30+
* Maps Svix headers to Standard Webhooks headers for compatibility
31+
*/
32+
function createStandardWebhookHeaders(request: Request): Record<string, string> {
33+
const headers: Record<string, string> = {};
34+
35+
// Map Svix headers to Standard Webhooks headers
36+
const svixId = request.headers.get(SVIX_ID_HEADER)?.trim();
37+
const svixTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER)?.trim();
38+
const svixSignature = request.headers.get(SVIX_SIGNATURE_HEADER)?.trim();
39+
40+
if (svixId) {
41+
headers[STANDARD_WEBHOOK_ID_HEADER] = svixId;
42+
}
43+
if (svixTimestamp) {
44+
headers[STANDARD_WEBHOOK_TIMESTAMP_HEADER] = svixTimestamp;
45+
}
46+
if (svixSignature) {
47+
headers[STANDARD_WEBHOOK_SIGNATURE_HEADER] = svixSignature;
48+
}
49+
50+
return headers;
51+
}
52+
53+
/**
54+
* Verifies the authenticity of a webhook request using Standard Webhooks. Returns a promise that resolves to the verified webhook event data.
2755
*
2856
* @param request - The request object.
2957
* @param options - Optional configuration object.
@@ -56,39 +84,53 @@ export * from './api/resources/Webhooks';
5684
*/
5785
export async function verifyWebhook(request: Request, options: VerifyWebhookOptions = {}): Promise<WebhookEvent> {
5886
const secret = options.signingSecret ?? getEnvVariable('CLERK_WEBHOOK_SIGNING_SECRET');
59-
const svixId = request.headers.get(SVIX_ID_HEADER);
60-
const svixTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER);
61-
const svixSignature = request.headers.get(SVIX_SIGNATURE_HEADER);
6287

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

69-
if (!svixId || !svixTimestamp || !svixSignature) {
70-
const missingHeaders = REQUIRED_SVIX_HEADERS.filter(header => !request.headers.has(header));
71-
return errorThrower.throw(`Missing required Svix headers: ${missingHeaders.join(', ')}`);
94+
// Check for required Svix headers
95+
const webhookId = request.headers.get(SVIX_ID_HEADER)?.trim();
96+
const webhookTimestamp = request.headers.get(SVIX_TIMESTAMP_HEADER)?.trim();
97+
const webhookSignature = request.headers.get(SVIX_SIGNATURE_HEADER)?.trim();
98+
99+
if (!webhookId || !webhookTimestamp || !webhookSignature) {
100+
const missingHeaders = [];
101+
102+
if (!webhookId) {
103+
missingHeaders.push(SVIX_ID_HEADER);
104+
}
105+
if (!webhookTimestamp) {
106+
missingHeaders.push(SVIX_TIMESTAMP_HEADER);
107+
}
108+
if (!webhookSignature) {
109+
missingHeaders.push(SVIX_SIGNATURE_HEADER);
110+
}
111+
112+
return errorThrower.throw(`Missing required webhook headers: ${missingHeaders.join(', ')}`);
72113
}
73114

74115
const body = await request.text();
75116

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

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

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

82-
// svixSignature can be a string with one or more space separated signatures
83-
if (svixSignature.split(' ').includes(constructedSignature)) {
84-
return errorThrower.throw('Incoming webhook does not have a valid signature');
128+
return {
129+
type: payload.type,
130+
object: 'event',
131+
data: payload.data,
132+
} as WebhookEvent;
133+
} catch (e) {
134+
return errorThrower.throw(`Unable to verify incoming webhook: ${e instanceof Error ? e.message : 'Unknown error'}`);
85135
}
86-
87-
const payload = JSON.parse(body);
88-
89-
return {
90-
type: payload.type,
91-
object: 'event',
92-
data: payload.data,
93-
} as WebhookEvent;
94136
}

0 commit comments

Comments
 (0)