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.
This commit is contained in:
2026-07-23 01:49:05 -04:00
parent 7287f10c9a
commit 0385090f32
2 changed files with 40 additions and 4 deletions

View File

@@ -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");

View File

@@ -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]
});
}