From 0385090f32223fc215012e929c83ae7bed4a54c6 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 23 Jul 2026 01:49:05 -0400 Subject: [PATCH] fix(clerk-webhook): sync emailVerified from Clerk verification status The webhook handler now derives emailVerified from the primary email's Clerk verification status (1 = verified, 0 = unverified) and writes it on both user.created upserts and user.updated mutations. This aligns manual test-user creation (scripts/create_test_user) with webhook-created users, which previously diverged (manual set emailVerified=1, webhook did not set it at all). - resolveEmailVerified(): new helper reads the primary email's verification.status from the Clerk payload. - INSERT/UPDATE SQL now includes emailVerified in the column list. - Tests assert emailVerified=1 for verified emails and emailVerified=0 for unverified emails. --- src/server/clerk-user-webhook.test.ts | 25 ++++++++++++++++++++++++- src/server/clerk-user-webhook.ts | 19 ++++++++++++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/server/clerk-user-webhook.test.ts b/src/server/clerk-user-webhook.test.ts index 4f47a2f..1d2adff 100644 --- a/src/server/clerk-user-webhook.test.ts +++ b/src/server/clerk-user-webhook.test.ts @@ -165,13 +165,14 @@ async function call( function getUserByClerkId(clerkUserId: string) { const row = db .prepare( - "SELECT id, clerkUserId, email, firstName, lastName, displayName, avatarUrl, provider, status FROM users WHERE clerkUserId = ?" + "SELECT id, clerkUserId, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status FROM users WHERE clerkUserId = ?" ) .get(clerkUserId) as | { id: string; clerkUserId: string; email: string | null; + emailVerified: number; firstName: string | null; lastName: string | null; displayName: string | null; @@ -194,6 +195,7 @@ describe("Clerk user.created webhook", () => { expect(user).toBeDefined(); expect(user!.clerkUserId).toBe("user_abc123"); expect(user!.email).toBe("jane@example.com"); + expect(user!.emailVerified).toBe(1); // Clerk marked the email verified expect(user!.firstName).toBe("Jane"); expect(user!.lastName).toBe("Doe"); expect(user!.displayName).toBe("Jane Doe"); @@ -228,6 +230,26 @@ describe("Clerk user.created webhook", () => { const user = getUserByClerkId("user_abc123"); expect(user!.displayName).toBe("janedoe"); }); + + it("records emailVerified=0 when Clerk marks the email unverified", async () => { + const res = await call( + sign( + userCreatedPayload({ + email_addresses: [ + { + id: "idn_1", + email_address: "unverified@example.com", + verification: { status: "unverified" } + } + ], + primary_email_address_id: "idn_1" + }) + ) + ); + expect(res.status).toBe(200); + const user = getUserByClerkId("user_abc123"); + expect(user!.emailVerified).toBe(0); + }); }); describe("Clerk user.updated webhook", () => { @@ -245,6 +267,7 @@ describe("Clerk user.updated webhook", () => { expect(after!.id).toBe(localId); // local UUID stable expect(after!.clerkUserId).toBe("user_abc123"); expect(after!.email).toBe("jane.new@example.com"); + expect(after!.emailVerified).toBe(1); // verification status carried through update expect(after!.lastName).toBe("Smith"); expect(after!.displayName).toBe("Jane Smith"); expect(after!.avatarUrl).toBe("https://cdn.clerk.com/avatar2.png"); diff --git a/src/server/clerk-user-webhook.ts b/src/server/clerk-user-webhook.ts index 8f26be0..80311da 100644 --- a/src/server/clerk-user-webhook.ts +++ b/src/server/clerk-user-webhook.ts @@ -65,6 +65,15 @@ function resolveEmail(data: ClerkUserData): string | null { return primary?.email_address ?? null; } +/** Resolve the primary email's verification status (1 = verified, 0 = unverified). */ +function resolveEmailVerified(data: ClerkUserData): number { + const addresses = data.email_addresses ?? []; + const primaryId = data.primary_email_address_id ?? null; + const primary = + addresses.find((e) => e.id === primaryId) ?? addresses[0] ?? null; + return primary?.verification?.status === "verified" ? 1 : 0; +} + /** Resolve a display name (first + last, falling back to username). */ function resolveDisplayName(data: ClerkUserData): string | null { const first = (data.first_name ?? "").trim(); @@ -164,6 +173,7 @@ export async function handleClerkUserWebhook(opts: { const data = evt.data; const clerkUserId = data.id; const email = resolveEmail(data); + const emailVerified = resolveEmailVerified(data); const firstName = data.first_name ?? null; const lastName = data.last_name ?? null; const displayName = resolveDisplayName(data); @@ -174,10 +184,11 @@ export async function handleClerkUserWebhook(opts: { // ON CONFLICT instead of erroring on a duplicate. await conn.execute({ sql: `INSERT INTO users - (id, clerkUserId, email, firstName, lastName, displayName, avatarUrl, provider, status) - VALUES (?, ?, ?, ?, ?, ?, ?, 'clerk', 'active') + (id, clerkUserId, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'clerk', 'active') ON CONFLICT(clerkUserId) DO UPDATE SET email = excluded.email, + emailVerified = excluded.emailVerified, firstName = excluded.firstName, lastName = excluded.lastName, displayName = excluded.displayName, @@ -187,6 +198,7 @@ export async function handleClerkUserWebhook(opts: { crypto.randomUUID(), clerkUserId, email, + emailVerified, firstName, lastName, displayName, @@ -198,13 +210,14 @@ export async function handleClerkUserWebhook(opts: { await conn.execute({ sql: `UPDATE users SET email = ?, + emailVerified = ?, firstName = ?, lastName = ?, displayName = ?, avatarUrl = ?, updatedAt = datetime('now') WHERE clerkUserId = ?`, - args: [email, firstName, lastName, displayName, avatarUrl, clerkUserId] + args: [email, emailVerified, firstName, lastName, displayName, avatarUrl, clerkUserId] }); }