fix(login): prevent duplicate email-code verification issue (#11893)

<!--
Please inform yourself about the contribution guidelines on submitting a
PR here:
https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr.
Take note of how PR/commit titles should be written and replace the
template texts in the sections below. Don't remove any of the sections.
It is important that the commit history clearly shows what is changed
and why.
Important: By submitting a contribution you agree to the terms from our
Licensing Policy as described here:
https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions.
-->

# Which Problems Are Solved

- With Login V2 and EMAIL_VERIFICATION=true, opening the verify page
with send=true can trigger email code generation twice.
- The second code invalidates the first one, so users may receive two
emails where the first code is always invalid.
- This causes failed verification attempts and blocks onboarding for
affected users.

# How the Problems Are Solved

- Removed side-effectful email-code sending from direct server component
execution path.
- Ensured code sending is executed only once in a safe server-side flow,
so React Server Component re-fetches do not generate additional codes.
- Kept verify flow behavior intact while preventing duplicate code
invalidation.

# Additional Changes

- Added/updated logic around the verify flow to make repeated
render/re-fetch paths idempotent for email-code sending.
- Improved reliability of the Login V2 email verification step under App
Router navigation behavior.

# Additional Context

Fixes #11857
- Tested manually with Login V2 and EMAIL_VERIFICATION=true by
completing a new user registration flow to /verify?send=true.
- Verified that only one verification email is sent and the first
received code is valid (no duplicate invalidating code generated).
Related bug report with reproduction details:
[https://github.com/zitadel/zitadel/issues/11857](vscode-file://vscode-app/Applications/Visual%20Studio%20Code.app/Contents/Resources/app/out/vs/code/electron-browser/workbench/workbench.html)

---------

Co-authored-by: Max Peintner <max@caos.ch>
This commit is contained in:
mkdavor
2026-03-31 12:04:28 +00:00
committed by GitHub
co-authored by Max Peintner
parent 7c3d26b23b
commit 604d5cb0a9
4 changed files with 200 additions and 74 deletions
+2 -61
View File
@@ -1,11 +1,9 @@
import { Alert, AlertType } from "@/components/alert";
import { Alert } from "@/components/alert";
import { DynamicTheme } from "@/components/dynamic-theme";
import { Translated } from "@/components/translated";
import { UserAvatar } from "@/components/user-avatar";
import { VerifyForm } from "@/components/verify-form";
import { UNKNOWN_USER_ID } from "@/lib/constants";
import { getPublicHostWithProtocol } from "@/lib/server/host";
import { sendEmailCode, sendInviteEmailCode } from "@/lib/server/verify";
import { getServiceConfig } from "@/lib/service-url";
import { loadMostRecentSession } from "@/lib/session";
import { getBrandingSettings, getLoginSettings, getUserByID, searchUsers } from "@/lib/zitadel";
@@ -36,39 +34,10 @@ export default async function Page(props: { searchParams: Promise<any> }) {
let id: string | undefined;
let loginSettings: LoginSettings | undefined;
let error: string | undefined;
const doSend = send === "true";
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
const autoSubmitCode = process.env.NEXT_PUBLIC_AUTO_SUBMIT_CODE === "true";
async function sendEmail(userId: string) {
const hostWithProtocol = await getPublicHostWithProtocol(_headers);
if (invite === "true") {
await sendInviteEmailCode({
userId,
urlTemplate:
`${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` +
(requestId ? `&requestId=${requestId}` : ""),
}).catch((apiError) => {
console.error("Could not send invitation email", apiError);
error = "inviteSendFailed";
});
} else {
await sendEmailCode({
userId,
urlTemplate:
`${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` +
(requestId ? `&requestId=${requestId}` : ""),
}).catch((apiError) => {
console.error("Could not send verification email", apiError);
error = "emailSendFailed";
});
}
}
if ("loginName" in searchParams) {
sessionFactors = await loadMostRecentSession({
serviceConfig,
@@ -84,15 +53,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
// ignore error, as we might not have a session yet
return undefined;
});
if (doSend && sessionFactors?.factors?.user?.id) {
await sendEmail(sessionFactors.factors.user.id);
}
} else if ("userId" in searchParams && userId) {
if (doSend) {
await sendEmail(userId);
}
const userResponse = await getUserByID({ serviceConfig, userId });
if (userResponse) {
user = userResponse.user;
@@ -128,11 +89,6 @@ export default async function Page(props: { searchParams: Promise<any> }) {
if (user.type.case === "human") {
human = user.type.value as HumanUser;
}
// If we found the user and need to send email, do it now
if (doSend && id) {
await sendEmail(id);
}
} else if (loginSettings?.ignoreUnknownUsernames) {
// Prevent enumeration by pretending we found a user
id = UNKNOWN_USER_ID;
@@ -189,14 +145,6 @@ export default async function Page(props: { searchParams: Promise<any> }) {
</div>
<div className="w-full">
{error && (
<div className="py-4">
<Alert>
<Translated i18nKey={`errors.${error}`} namespace="verify" />
</Alert>
</div>
)}
{!id && (
<div className="py-4">
<Alert>
@@ -205,14 +153,6 @@ export default async function Page(props: { searchParams: Promise<any> }) {
</div>
)}
{id && send && (
<div className="w-full py-4">
<Alert type={AlertType.INFO}>
<Translated i18nKey="verify.codeSent" namespace="verify" />
</Alert>
</div>
)}
{id && (
<VerifyForm
loginName={loginName}
@@ -222,6 +162,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
isInvite={invite === "true"}
requestId={requestId}
submit={autoSubmitCode}
doSend={doSend}
/>
)}
</div>
+32 -4
View File
@@ -3,7 +3,7 @@
import { Alert, AlertType } from "@/components/alert";
import { handleServerActionResponse } from "@/lib/client-utils";
import { UNKNOWN_USER_ID } from "@/lib/constants";
import { resendVerification, sendVerification } from "@/lib/server/verify";
import { initialSendVerification, resendVerification, sendVerification } from "@/lib/server/verify";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
@@ -27,9 +27,10 @@ type Props = {
isInvite: boolean;
requestId?: string;
submit: boolean;
doSend?: boolean;
};
export function VerifyForm({ userId, loginName, organization, requestId, code, isInvite, submit }: Props) {
export function VerifyForm({ userId, loginName, organization, requestId, code, isInvite, submit, doSend }: Props) {
const router = useRouter();
const { register, handleSubmit, formState } = useForm<Inputs>({
@@ -46,8 +47,27 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
const [loading, setLoading] = useState<boolean>(false);
const initialSendDone = useRef(false);
const [initialSendError, setInitialSendError] = useState<string>("");
const [codeSent, setCodeSent] = useState(false);
useEffect(() => {
if (doSend && userId && userId !== UNKNOWN_USER_ID && !initialSendDone.current) {
initialSendDone.current = true;
setError("");
initialSendVerification({ userId, isInvite, requestId })
.then(() => {
setCodeSent(true);
})
.catch(() => {
setInitialSendError(isInvite ? t("errors.couldNotResendInvite") : t("errors.couldNotResendEmail"));
});
}
}, [doSend, userId, isInvite, requestId, t]);
async function resendCode() {
setError("");
setInitialSendError("");
setLoading(true);
// do not send code for dummy userid that is set to prevent user enumeration
@@ -83,6 +103,7 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
const fcn = useCallback(
async function submitCodeAndContinue(value: Inputs): Promise<boolean | void> {
setError("");
setInitialSendError("");
setLoading(true);
try {
@@ -115,6 +136,13 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
return (
<>
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
{codeSent && !initialSendError && (
<div className="w-full py-4">
<Alert type={AlertType.INFO}>
<Translated i18nKey="verify.codeSent" namespace="verify" />
</Alert>
</div>
)}
<form className="w-full">
<Alert type={AlertType.INFO}>
<div className="flex flex-row">
@@ -146,9 +174,9 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
/>
</div>
{error && (
{(error || initialSendError) && (
<div className="py-4" data-testid="error">
<Alert>{error}</Alert>
<Alert>{error || initialSendError}</Alert>
</div>
)}
+115 -3
View File
@@ -1,7 +1,14 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { sendVerification } from "./verify";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { initialSendVerification, sendVerification } from "./verify";
import { getSession, getUserByID, listAuthenticationMethodTypes, verifyEmail } from "@/lib/zitadel";
import {
createInviteCode,
getSession,
getUserByID,
listAuthenticationMethodTypes,
verifyEmail,
sendEmailCode as zitadelSendEmailCode,
} from "@/lib/zitadel";
import { cookies } from "next/headers";
import { getSessionCookieByLoginName } from "../cookies";
import { createSessionAndUpdateCookie } from "./cookie";
@@ -14,6 +21,8 @@ vi.mock("@/lib/zitadel", () => ({
getSession: vi.fn(),
listAuthenticationMethodTypes: vi.fn(),
getLoginSettings: vi.fn(),
sendEmailCode: vi.fn(),
createInviteCode: vi.fn(),
}));
vi.mock("next/headers", () => ({
@@ -37,6 +46,10 @@ vi.mock("../fingerprint", () => ({
getOrSetFingerprintId: vi.fn(),
}));
vi.mock("./host", () => ({
getPublicHostWithProtocol: vi.fn(() => "https://example.com"),
}));
vi.mock("next-intl/server", () => ({
getTranslations: vi.fn(() => (key: string) => key),
}));
@@ -111,3 +124,102 @@ describe("sendVerification", () => {
expect(mockCreateSessionAndUpdateCookie).toHaveBeenCalled();
});
});
describe("initialSendVerification", () => {
let mockSendEmailCode: any;
let mockCreateInviteCode: any;
let originalBasePath: string | undefined;
beforeEach(() => {
originalBasePath = process.env.NEXT_PUBLIC_BASE_PATH;
process.env.NEXT_PUBLIC_BASE_PATH = "/ui/v2/login";
vi.clearAllMocks();
mockSendEmailCode = zitadelSendEmailCode;
mockCreateInviteCode = createInviteCode;
mockSendEmailCode.mockResolvedValue({});
mockCreateInviteCode.mockResolvedValue({});
});
afterEach(() => {
if (originalBasePath === undefined) {
delete process.env.NEXT_PUBLIC_BASE_PATH;
} else {
process.env.NEXT_PUBLIC_BASE_PATH = originalBasePath;
}
});
test("should call sendEmailCode with correct URL template for non-invite", async () => {
await initialSendVerification({
userId: "user-1",
isInvite: false,
});
expect(mockSendEmailCode).toHaveBeenCalledWith({
serviceConfig: {},
userId: "user-1",
urlTemplate: "https://example.com/ui/v2/login/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}",
});
expect(mockCreateInviteCode).not.toHaveBeenCalled();
});
test("should call createInviteCode with correct URL template for invite", async () => {
await initialSendVerification({
userId: "user-1",
isInvite: true,
});
expect(mockCreateInviteCode).toHaveBeenCalledWith({
serviceConfig: {},
userId: "user-1",
urlTemplate:
"https://example.com/ui/v2/login/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true",
});
expect(mockSendEmailCode).not.toHaveBeenCalled();
});
test("should include URL-encoded requestId in URL template", async () => {
await initialSendVerification({
userId: "user-1",
isInvite: false,
requestId: "req-123",
});
expect(mockSendEmailCode).toHaveBeenCalledWith({
serviceConfig: {},
userId: "user-1",
urlTemplate:
"https://example.com/ui/v2/login/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&requestId=req-123",
});
});
test("should URL-encode special characters in requestId", async () => {
await initialSendVerification({
userId: "user-1",
isInvite: false,
requestId: "req&id=injected",
});
expect(mockSendEmailCode).toHaveBeenCalledWith({
serviceConfig: {},
userId: "user-1",
urlTemplate:
"https://example.com/ui/v2/login/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&requestId=req%26id%3Dinjected",
});
});
test("should include invite=true and requestId for invite with requestId", async () => {
await initialSendVerification({
userId: "user-1",
isInvite: true,
requestId: "req-456",
});
expect(mockCreateInviteCode).toHaveBeenCalledWith({
serviceConfig: {},
userId: "user-1",
urlTemplate:
"https://example.com/ui/v2/login/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true&requestId=req-456",
});
});
});
+51 -6
View File
@@ -236,6 +236,25 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
);
}
function buildVerificationUrlTemplate(
hostWithProtocol: string,
basePath: string,
isInvite: boolean,
requestId?: string,
): string {
let urlTemplate = `${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}`;
if (isInvite) {
urlTemplate += "&invite=true";
}
if (requestId) {
urlTemplate += `&requestId=${encodeURIComponent(requestId)}`;
}
return urlTemplate;
}
type resendVerifyEmailCommand = {
userId: string;
isInvite: boolean;
@@ -249,14 +268,13 @@ export async function resendVerification(command: resendVerifyEmailCommand) {
const hostWithProtocol = await getPublicHostWithProtocol(_headers);
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
const urlTemplate = buildVerificationUrlTemplate(hostWithProtocol, basePath, command.isInvite, command.requestId);
return command.isInvite
? createInviteCode({
serviceConfig,
userId: command.userId,
urlTemplate:
`${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` +
(command.requestId ? `&requestId=${command.requestId}` : ""),
urlTemplate,
}).catch((error) => {
if (error.code === 9) {
return { error: t("errors.userAlreadyVerified") };
@@ -266,9 +284,7 @@ export async function resendVerification(command: resendVerifyEmailCommand) {
: zitadelSendEmailCode({
serviceConfig,
userId: command.userId,
urlTemplate:
`${hostWithProtocol}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` +
(command.requestId ? `&requestId=${command.requestId}` : ""),
urlTemplate,
});
}
@@ -290,3 +306,32 @@ export async function sendInviteEmailCode(command: SendEmailCommand) {
return createInviteCode({ serviceConfig, userId: command.userId, urlTemplate: command.urlTemplate });
}
type InitialSendVerificationCommand = {
userId: string;
isInvite: boolean;
requestId?: string;
};
export async function initialSendVerification(command: InitialSendVerificationCommand) {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const hostWithProtocol = await getPublicHostWithProtocol(_headers);
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
const urlTemplate = buildVerificationUrlTemplate(hostWithProtocol, basePath, command.isInvite, command.requestId);
if (command.isInvite) {
return createInviteCode({
serviceConfig,
userId: command.userId,
urlTemplate,
});
} else {
return zitadelSendEmailCode({
serviceConfig,
userId: command.userId,
urlTemplate,
});
}
}