From 67a02a6aebe71a2999b88e6b0adbbedffcf79275 Mon Sep 17 00:00:00 2001 From: Liam Neville Date: Tue, 10 Feb 2026 19:14:27 +0300 Subject: [PATCH] feat(login): add support for disabling automatic email verification (#11515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved Enterprise email link scanners (used by organizations for security) pre-fetch URLs in emails to check for malicious content. In Login V2, email verification codes are automatically redeemed when the verification page loads. This causes link scanners to inadvertently consume one-time verification codes before users can click them, resulting in "code already used" errors. # How the Problems Are Solved - Changed the default behaviour so that verification codes are **not** auto-submitted on page load — users must click a Submit button to redeem the code - Added a new environment variable `NEXT_PUBLIC_AUTO_SUBMIT_CODE` that can be set to `true` to re-enable auto-submission for instances where link scanners are not a concern - The env var is named generically so it can be extended to other auto-submit flows (e.g. Email OTP) in the future # Additional Changes None # Additional Context Closes #11406 --- .../guides/integrate/login-ui/login-app.mdx | 3 + .../integration/integration/invite.cy.ts | 2 + .../integration/integration/verify.cy.ts | 1 + apps/login/next-env-vars.d.ts | 8 +++ apps/login/src/app/(login)/verify/page.tsx | 2 + .../login/src/components/verify-form.test.tsx | 57 +++++++++++++++++++ apps/login/src/components/verify-form.tsx | 7 ++- 7 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 apps/login/src/components/verify-form.test.tsx diff --git a/apps/docs/content/guides/integrate/login-ui/login-app.mdx b/apps/docs/content/guides/integrate/login-ui/login-app.mdx index 8632201a6..dc60e150f 100644 --- a/apps/docs/content/guides/integrate/login-ui/login-app.mdx +++ b/apps/docs/content/guides/integrate/login-ui/login-app.mdx @@ -144,4 +144,7 @@ Also ensure your login domain is registered on your instance by adding it as a [ If you want to enforce users to have their email verified, you can set the optional `EMAIL_VERIFICATION` variable to `true` in your environment and your users will be enforced to verify their email address before they can log in. +By default, verification codes are not automatically submitted on page load. This protects against enterprise email link scanners that pre-fetch URLs and could inadvertently consume one-time codes before users click them. +If you want to enable automatic code submission (e.g. for a smoother UX when link scanners are not a concern), set `NEXT_PUBLIC_AUTO_SUBMIT_CODE` to `true`. + ![](../../../../public/img/deploy-to-vercel.png) diff --git a/apps/login/integration/integration/invite.cy.ts b/apps/login/integration/integration/invite.cy.ts index bf4d46554..a68bb5469 100644 --- a/apps/login/integration/integration/invite.cy.ts +++ b/apps/login/integration/integration/invite.cy.ts @@ -93,6 +93,7 @@ describe("verify invite", () => { stub("zitadel.user.v2.UserService", "VerifyInviteCode"); cy.visit("/verify?userId=221394658884845598&code=abc&invite=true"); + cy.get("[data-testid=submit-button]").click(); cy.url().should("include", Cypress.config().baseUrl + "/authenticator/set"); }); @@ -105,6 +106,7 @@ describe("verify invite", () => { // TODO: Avoid uncaught exception in application cy.once("uncaught:exception", () => false); cy.visit("/verify?userId=221394658884845598&code=abc&invite=true"); + cy.get("[data-testid=submit-button]").click(); cy.contains("Could not verify invite", { timeout: 10_000 }); }); }); diff --git a/apps/login/integration/integration/verify.cy.ts b/apps/login/integration/integration/verify.cy.ts index c50101a95..ed3182819 100644 --- a/apps/login/integration/integration/verify.cy.ts +++ b/apps/login/integration/integration/verify.cy.ts @@ -90,6 +90,7 @@ describe("verify email", () => { // TODO: Avoid uncaught exception in application cy.once("uncaught:exception", () => false); cy.visit("/verify?userId=221394658884845598&code=abc"); + cy.get("[data-testid=submit-button]").click(); cy.contains("Could not verify email"); }); }); diff --git a/apps/login/next-env-vars.d.ts b/apps/login/next-env-vars.d.ts index 833239f13..a9370721f 100644 --- a/apps/login/next-env-vars.d.ts +++ b/apps/login/next-env-vars.d.ts @@ -44,5 +44,13 @@ declare namespace NodeJS { * Optional: The application name shown in the login and invite emails */ NEXT_PUBLIC_APPLICATION_NAME?: string; + + /** + * Optional: Enable automatic code submission on page load. + * Set to "true" to auto-submit verification codes (e.g. email verification, Email OTP). + * Default behavior (undefined) requires users to click a Submit button, + * which is safer for environments with enterprise email link scanners. + */ + NEXT_PUBLIC_AUTO_SUBMIT_CODE?: string; } } diff --git a/apps/login/src/app/(login)/verify/page.tsx b/apps/login/src/app/(login)/verify/page.tsx index afa1216a1..687769c2f 100644 --- a/apps/login/src/app/(login)/verify/page.tsx +++ b/apps/login/src/app/(login)/verify/page.tsx @@ -41,6 +41,7 @@ export default async function Page(props: { searchParams: Promise }) { 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); @@ -220,6 +221,7 @@ export default async function Page(props: { searchParams: Promise }) { code={code} isInvite={invite === "true"} requestId={requestId} + submit={autoSubmitCode} /> )} diff --git a/apps/login/src/components/verify-form.test.tsx b/apps/login/src/components/verify-form.test.tsx new file mode 100644 index 000000000..f284cd9a2 --- /dev/null +++ b/apps/login/src/components/verify-form.test.tsx @@ -0,0 +1,57 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { VerifyForm } from "./verify-form"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/lib/server/verify", () => ({ + sendVerification: vi.fn(), + resendVerification: vi.fn(), +})); + +describe("VerifyForm", () => { + let mockSendVerification: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + + const { sendVerification } = await import("@/lib/server/verify"); + mockSendVerification = vi.mocked(sendVerification); + mockSendVerification.mockResolvedValue({ redirect: "/success" }); + }); + + afterEach(cleanup); + + describe("Auto-submit Behavior", () => { + test("should call sendVerification automatically when submit=true", async () => { + render(); + + await waitFor(() => { + expect(mockSendVerification).toHaveBeenCalledWith( + expect.objectContaining({ + code: "123456", + userId: "user-1", + }), + ); + }); + }); + + test("should prefill code but not auto-submit when submit=false", () => { + render(); + + const input = screen.getByTestId("code-text-input"); + expect(input).toHaveValue("123456"); + + const submitButton = screen.getByTestId("submit-button"); + expect(submitButton).toBeInTheDocument(); + + expect(mockSendVerification).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/login/src/components/verify-form.tsx b/apps/login/src/components/verify-form.tsx index 0ee2e7d41..5d303a8c4 100644 --- a/apps/login/src/components/verify-form.tsx +++ b/apps/login/src/components/verify-form.tsx @@ -26,9 +26,10 @@ type Props = { code?: string; isInvite: boolean; requestId?: string; + submit: boolean; }; -export function VerifyForm({ userId, loginName, organization, requestId, code, isInvite }: Props) { +export function VerifyForm({ userId, loginName, organization, requestId, code, isInvite, submit }: Props) { const router = useRouter(); const { register, handleSubmit, formState } = useForm({ @@ -104,11 +105,11 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i ); useEffect(() => { - if (code && code !== processedCode.current) { + if (submit && code && code !== processedCode.current) { processedCode.current = code; fcn({ code }); } - }, [code, fcn]); + }, [submit, code, fcn]); return ( <>