Commit 84234355f48 for woocommerce

commit 84234355f48d814a09652668b957e1d92f044e4d
Author: Mike Jolley <mike.jolley@me.com>
Date:   Fri Jun 26 11:51:12 2026 +0100

    Follow up: Replace email-verification OTP with a login-gated link (#65971)

    * Replace email-verification OTP with a login-gated magic link

    Follow-up to #65822 that reduces complexity: swaps the 6-digit OTP entry
    flow for a one-time link gated on being logged in as the link's target
    user. Removes the OTP attempt limits, cumulative-failure lockout, and the
    code-entry sub-page; the login gate replaces brute-force protection.

    * Render email-verification result notices from a query param to prevent stacking

    The send/confirm handlers queued feedback via wc_add_notice and redirected, so
    the WC session could accumulate several notices across rapid clicks (e.g. "sent"
    + "sent recently"). Each handler now redirects with a wc_verify_notice=<code>
    result code instead, and print_result_notice() renders only the current
    request's notice on the orders panel — re-running an action can no longer stack
    notices. render_prompt suppresses its within-window "check your inbox" line when
    a result notice is already showing.

    * Verify on the authenticated GET, mirroring WordPress core's email-change links

    Drops the auto-submitting POST interstitial. The verify-link now completes on
    the authenticated GET, gated on get_current_user_id() === the link's target
    user — the same shape as core's email-change confirmation links (options.php
    `adminhash`, user-edit.php `newuseremail`): a sensitive change on an
    authenticated GET carrying an unguessable, single-use, email-bound key, with no
    nonce (the secret key is the CSRF defence, the login gate is the authority).

    A prefetch or any logged-out visit only ever reaches the My Account login and
    never touches the key; a visitor logged in as a different account is refused
    without consuming it. Adds no-referrer + no-cache on the verify response since
    the page is front-end (core skips this on its noindex admin screens), and
    suppresses the success notice when the address is already verified.

    Removes the interstitial, its verify nonce, and the POST routing; rewrites the
    controller tests for the GET flow, including an explicit auth-lockdown test that
    proves the same valid link verifies only when opened as the target.

diff --git a/plugins/woocommerce/changelog/update-email-verification-magic-link b/plugins/woocommerce/changelog/update-email-verification-magic-link
new file mode 100644
index 00000000000..d46a07d84ee
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-email-verification-magic-link
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Customer email verification now confirms via a one-time login-gated link instead of a 6-digit code.
diff --git a/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreview.php b/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreview.php
index 20b15ebfd55..d8eeeaa9d0a 100644
--- a/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreview.php
+++ b/plugins/woocommerce/src/Internal/Admin/EmailPreview/EmailPreview.php
@@ -218,8 +218,8 @@ class EmailPreview {
 				$this->email->user_id = 0;
 			}

-			if ( property_exists( $this->email, 'verify_code' ) ) {
-				$this->email->verify_code = '123456';
+			if ( property_exists( $this->email, 'verify_url' ) ) {
+				$this->email->verify_url = 'https://example.com/verify-email';
 			}

 			$this->email->set_object( $object );
diff --git a/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php b/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php
index a639deff89b..39cb8bc8d31 100644
--- a/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php
+++ b/plugins/woocommerce/src/Internal/CustomerEmailVerification/EmailVerificationService.php
@@ -9,60 +9,29 @@ use Automattic\WooCommerce\Internal\Utilities\Users;
  * Service class providing the foundational primitives for customer email verification.
  *
  * This class is the single source of truth for whether a customer has proven they
- * control their account email address. It manages the verified status meta and the
- * short-lived, single-use numeric code (OTP) emailed to the customer, together with
- * the per-code attempt limit and the attempt-budget lockout that protect it.
+ * control their account email address. It manages the verified-status meta and the
+ * short-lived, single-use verification key carried by the emailed verify-link, together
+ * with the helpers consumed by the rest of the email-verification feature.
+ *
+ * The verify-link is only ever *completed* by a request authenticated as the link's target
+ * user (see {@see VerificationController::handle_confirm_submission()}), so the key needs no
+ * brute-force protection: it is high-entropy ({@see self::KEY_LENGTH} chars), single-use, and
+ * expires after {@see self::KEY_TTL}.
  *
  * @since 11.0.0
  */
 class EmailVerificationService {

 	/**
-	 * Result of {@see self::verify_code()}: the code matched and the user is now verified.
-	 */
-	public const RESULT_OK = 'ok';
-
-	/**
-	 * Result of {@see self::verify_code()}: the code was wrong but attempts remain on it.
-	 */
-	public const RESULT_WRONG = 'wrong';
-
-	/**
-	 * Result of {@see self::verify_code()}: the code was wrong and has now used up its attempts.
-	 */
-	public const RESULT_BURNED = 'burned';
-
-	/**
-	 * Result of {@see self::verify_code()}: the code has expired (not counted as a failed guess).
+	 * Length of the generated verification key. A 20-char alphanumeric key is high-entropy enough that
+	 * it needs no attempt limiting (unlike a 6-digit code), so this flow carries no lockout machinery.
 	 */
-	public const RESULT_EXPIRED = 'expired';
+	private const KEY_LENGTH = 20;

 	/**
-	 * Result of {@see self::verify_code()}: there is no pending code to check.
+	 * How long a freshly minted verification key remains valid.
 	 */
-	public const RESULT_NONE = 'none';
-
-	/**
-	 * Result of {@see self::verify_code()}: the attempt budget is exhausted; the user is locked out.
-	 */
-	public const RESULT_LOCKED = 'locked';
-
-	/**
-	 * How long a freshly minted code remains valid.
-	 */
-	private const OTP_TTL = 10 * MINUTE_IN_SECONDS;
-
-	/**
-	 * Wrong guesses allowed against a single code before it is burned and a new one must be requested.
-	 */
-	private const MAX_ATTEMPTS = 3;
-
-	/**
-	 * Total guesses allowed (across all codes) before the user is permanently locked out of the code
-	 * flow and must contact the store owner (who can verify them from the admin). Stored as a
-	 * countdown in self::ATTEMPTS_META.
-	 */
-	private const ATTEMPT_BUDGET = 10;
+	private const KEY_TTL = DAY_IN_SECONDS;

 	/**
 	 * User meta key that stores the verified email address (lower-cased).
@@ -71,24 +40,16 @@ class EmailVerificationService {
 	private const VERIFIED_META = '_wc_email_verified';

 	/**
-	 * User meta key that stores the pending code as "{timestamp}:{code_hash}:{email_hash}:{attempts}".
-	 * Overwritten on every new code; deleted when the code is consumed, burned, or the user verifies.
+	 * User meta key that stores the verification token as "{timestamp}:{key_hash}:{email_hash}".
+	 * Overwritten on every new key; deleted when the key is consumed or the user verifies.
 	 */
 	private const KEY_META = '_wc_email_verification_key';

-	/**
-	 * User meta key for the number of guesses remaining, as a plain integer counting down from
-	 * self::ATTEMPT_BUDGET to 0 (locked out). Spans codes — requesting a new code does not reset it —
-	 * so the lockout can't be sidestepped by re-requesting. See {@see self::claim_attempt()} for why
-	 * it counts down rather than up.
-	 */
-	private const ATTEMPTS_META = '_wc_email_verification_attempts';
-
 	/**
 	 * The user's account email, lower-cased, or null when the user does not exist.
 	 *
 	 * Lower-casing here is the single normalisation point, so the verified-status match and the
-	 * code's email-binding hash stay consistent however the address was capitalised.
+	 * key's email-binding hash stay consistent however the address was capitalised.
 	 *
 	 * @param int $user_id WordPress user ID.
 	 * @return string|null
@@ -121,9 +82,9 @@ class EmailVerificationService {
 	/**
 	 * Mark the given user as having verified their current account email address.
 	 *
-	 * Stores the verified email address, clears any pending code and the attempts counter, and
-	 * fires the {@see 'woocommerce_customer_email_verified'} action. No-ops if the
-	 * user is already verified for their current email.
+	 * Stores the verified email address, clears any pending key, and fires the
+	 * {@see 'woocommerce_customer_email_verified'} action. No-ops if the user is already
+	 * verified for their current email.
 	 *
 	 * @since 11.0.0
 	 *
@@ -144,7 +105,6 @@ class EmailVerificationService {
 		// Storing the email (not a bool) lets the status self-invalidate if the account email later changes.
 		Users::update_site_user_meta( $user_id, self::VERIFIED_META, $account_email );
 		Users::delete_site_user_meta( $user_id, self::KEY_META );
-		Users::delete_site_user_meta( $user_id, self::ATTEMPTS_META );

 		/**
 		 * Fires after a customer has verified their email address.
@@ -159,8 +119,8 @@ class EmailVerificationService {
 	/**
 	 * Clear the email-verification status for the given user.
 	 *
-	 * Removes the verified-email meta, any pending code, and the remaining-attempts counter,
-	 * effectively resetting the user to a clean unverified state (also lifting any lockout).
+	 * Removes both the verified-email meta and any pending verification key,
+	 * effectively resetting the user to an unverified state.
 	 *
 	 * @since 11.0.0
 	 *
@@ -170,214 +130,134 @@ class EmailVerificationService {
 	public function clear_verification( int $user_id ): void {
 		Users::delete_site_user_meta( $user_id, self::VERIFIED_META );
 		Users::delete_site_user_meta( $user_id, self::KEY_META );
-		Users::delete_site_user_meta( $user_id, self::ATTEMPTS_META );
 	}

 	/**
-	 * Generate and store a one-time numeric verification code for the given user.
-	 *
-	 * The plaintext 6-digit code is returned for inclusion in the verification email. The stored
-	 * value is a "{timestamp}:{code_hash}:{email_hash}:{attempts}" tuple so the plaintext is never
-	 * persisted, the code expires after {@see self::OTP_TTL}, and the email hash binds the code to
-	 * the account email in effect at issuance (a code emailed to one address can never verify a
-	 * different address the account is later switched to). The attempt counter starts at zero.
+	 * Generate and store a one-time email-verification key for the given user.
 	 *
-	 * Minting a new code does not reset the remaining-attempts counter, so the lockout cannot be
-	 * sidestepped by simply requesting fresh codes.
+	 * The plaintext key is returned for inclusion in the verification email link. The stored value is
+	 * a "{timestamp}:{key_hash}:{email_hash}" triplet so the plaintext is never persisted, the key
+	 * expires after {@see self::KEY_TTL}, and the email hash binds the key to the account email in
+	 * effect at issuance (a key emailed to one address can never verify a different address the account
+	 * is later switched to).
 	 *
 	 * @since 11.0.0
 	 *
 	 * @param int $user_id WordPress user ID.
-	 * @return string The plaintext 6-digit code.
+	 * @return string The plaintext verification key.
 	 */
-	public function create_code( int $user_id ): string {
-		$code          = $this->generate_code();
+	public function create_verification_key( int $user_id ): string {
+		$key           = wp_generate_password( self::KEY_LENGTH, false );
 		$account_email = $this->get_account_email( $user_id );
 		$email_hash    = null !== $account_email ? wp_fast_hash( $account_email ) : '';

-		// Seed the attempts counter only when absent: pre-creating the row keeps verify_code()'s
-		// compare-and-swap an update (never a racy insert), and not resetting it on resend means
-		// re-requesting codes can't lift a lockout in progress.
-		if ( '' === (string) Users::get_site_user_meta( $user_id, self::ATTEMPTS_META ) ) {
-			Users::update_site_user_meta( $user_id, self::ATTEMPTS_META, (string) self::ATTEMPT_BUDGET );
-		}
+		Users::update_site_user_meta( $user_id, self::KEY_META, time() . ':' . wp_fast_hash( $key ) . ':' . $email_hash );

-		Users::update_site_user_meta( $user_id, self::KEY_META, time() . ':' . wp_fast_hash( $code ) . ':' . $email_hash . ':0' );
+		return $key;
+	}

-		return $code;
+	/**
+	 * Build a one-time email-verification URL for the given user.
+	 *
+	 * Mints a fresh verification key and returns the My Account URL carrying that key and the user ID
+	 * as query args, ready to drop into an email. The matching reader is
+	 * {@see VerificationController::maybe_process_request()}.
+	 *
+	 * @since 11.0.0
+	 *
+	 * @param int $user_id WordPress user ID.
+	 * @return string The verification URL.
+	 */
+	public function build_verification_url( int $user_id ): string {
+		return $this->build_verification_url_for_key( $user_id, $this->create_verification_key( $user_id ) );
 	}

 	/**
-	 * Verify a submitted code for the given user and record the outcome.
+	 * Build the My Account verify-link URL carrying a specific (already-issued) key.
 	 *
-	 * Each guess first claims a slot from the remaining-attempts budget via {@see self::claim_attempt()},
-	 * so concurrent submissions can't slip past the cap. Expired, missing, or email-mismatched codes
-	 * return before a guess is claimed, so they never count against the customer. A correct code marks
-	 * the user verified; reaching {@see self::MAX_ATTEMPTS} on one code burns it (a fresh one must be
-	 * requested) and exhausting the budget locks the user out permanently.
+	 * Unlike {@see self::build_verification_url()} this mints nothing — it re-emits an existing key, e.g.
+	 * to bounce a logged-out visitor back to the link they opened so they can finish after signing in.
 	 *
 	 * @since 11.0.0
 	 *
 	 * @param int    $user_id WordPress user ID.
-	 * @param string $code    The plaintext code submitted by the customer.
-	 * @return string One of the RESULT_* constants.
+	 * @param string $key     Plaintext verification key to embed.
+	 * @return string The verification URL.
 	 */
-	public function verify_code( int $user_id, string $code ): string {
-		$remaining = $this->attempts_remaining( $user_id );
+	public function build_verification_url_for_key( int $user_id, string $key ): string {
+		return add_query_arg(
+			array(
+				'wc_verify_email_key'  => $key,
+				'wc_verify_email_user' => $user_id,
+			),
+			wc_get_page_permalink( 'myaccount' )
+		);
+	}

-		if ( null !== $remaining && $remaining <= 0 ) {
-			return self::RESULT_LOCKED;
+	/**
+	 * Validate a plaintext verification key against the stored hash for the given user.
+	 *
+	 * Returns false if no key is stored, if the key has expired, if the account email has changed since
+	 * the key was issued, or if the key does not match the stored hash.
+	 *
+	 * @since 11.0.0
+	 *
+	 * @param int    $user_id WordPress user ID.
+	 * @param string $key     The plaintext verification key to check.
+	 * @return bool True when the key is valid and has not expired.
+	 */
+	public function check_verification_key( int $user_id, string $key ): bool {
+		if ( '' === $key ) {
+			return false;
 		}

 		$parsed = $this->parse_stored_key( $user_id );

 		if ( null === $parsed ) {
-			return self::RESULT_NONE;
+			return false;
 		}

-		$account_email = $this->get_account_email( $user_id );
+		list( $timestamp, $hash, $email_hash ) = $parsed;

-		if ( null === $account_email ) {
-			return self::RESULT_NONE;
+		if ( time() - $timestamp > self::KEY_TTL ) {
+			return false;
 		}

-		list( $timestamp, $hash, $email_hash, $attempts ) = $parsed;
-
-		if ( time() - $timestamp > self::OTP_TTL ) {
-			// Expired: a timeout, not a guess. Clear the dead code without penalising the customer.
-			Users::delete_site_user_meta( $user_id, self::KEY_META );
-			return self::RESULT_EXPIRED;
-		}
-
-		// The code is void if the account email no longer matches the one it was minted for.
-		if ( ! wp_verify_fast_hash( $account_email, $email_hash ) ) {
-			Users::delete_site_user_meta( $user_id, self::KEY_META );
-			return self::RESULT_NONE;
-		}
-
-		// A live code exists, so create_code() must have created the counter row. If it is somehow
-		// missing, fail closed rather than letting the compare-and-swap re-insert it with a fresh budget.
-		if ( null === $remaining ) {
-			return self::RESULT_LOCKED;
-		}
-
-		// Claim this guess by decrementing the remaining budget before comparing. If another request
-		// moved the counter first, we lose the swap and turn away without a guess (no double-counting).
-		if ( ! $this->claim_attempt( $user_id, $remaining ) ) {
-			return self::RESULT_WRONG;
-		}
-
-		if ( '' !== $code && wp_verify_fast_hash( $code, $hash ) ) {
-			$this->mark_verified( $user_id );
-			return self::RESULT_OK;
-		}
-
-		// Wrong guess. The budget has already dropped to $remaining - 1.
-		if ( $remaining - 1 <= 0 ) {
-			// That was the final allowed guess: lock out and drop the live code.
-			Users::delete_site_user_meta( $user_id, self::KEY_META );
-			return self::RESULT_LOCKED;
-		}
-
-		++$attempts;
+		// The key is void if the account email no longer matches the one it was minted for.
+		$account_email = $this->get_account_email( $user_id );

-		if ( $attempts >= self::MAX_ATTEMPTS ) {
-			// Burn this code; the customer must request a fresh one.
-			Users::delete_site_user_meta( $user_id, self::KEY_META );
-			return self::RESULT_BURNED;
+		if ( null === $account_email || '' === $email_hash || ! wp_verify_fast_hash( $account_email, $email_hash ) ) {
+			return false;
 		}

-		Users::update_site_user_meta(
-			$user_id,
-			self::KEY_META,
-			$timestamp . ':' . $hash . ':' . $email_hash . ':' . $attempts
-		);
-
-		return self::RESULT_WRONG;
+		return wp_verify_fast_hash( $key, $hash );
 	}

 	/**
-	 * Atomically claim a guess by decrementing the remaining-attempts budget via a compare-and-swap.
+	 * Whether the user currently has a pending (minted, unexpired) verification key awaiting use.
 	 *
-	 * Moves the counter from $remaining to $remaining - 1 only while it still equals $remaining, so
-	 * concurrent submissions are serialised into distinct slots and at most ATTEMPT_BUDGET ever pass.
-	 * $remaining is always 1..ATTEMPT_BUDGET here, so the previous value is never "0" — which
-	 * update_user_meta() treats as empty and would ignore, making the swap non-conditional. The row is
-	 * pre-created by {@see self::create_code()} so this only ever updates, never inserts (which would
-	 * race into duplicate rows). Returns false when another request moved the counter first.
-	 *
-	 * @param int $user_id   WordPress user ID.
-	 * @param int $remaining The remaining count this request observed (>= 1).
-	 * @return bool True when this request claimed the slot.
-	 */
-	private function claim_attempt( int $user_id, int $remaining ): bool {
-		return (bool) Users::update_site_user_meta( $user_id, self::ATTEMPTS_META, (string) ( $remaining - 1 ), (string) $remaining );
-	}
-
-	/**
-	 * Whether the user currently has a pending (minted, unexpired) code awaiting entry.
-	 *
-	 * Used to decide whether the My Account prompt shows the code-entry form or the "send code"
-	 * call to action.
+	 * Used to decide whether the My Account prompt shows the "check your inbox" notice or the "send a
+	 * confirmation link" call to action.
 	 *
 	 * @since 11.0.0
 	 *
 	 * @param int $user_id WordPress user ID.
 	 * @return bool
 	 */
-	public function has_pending_code( int $user_id ): bool {
+	public function has_pending_key( int $user_id ): bool {
 		$parsed = $this->parse_stored_key( $user_id );

-		return null !== $parsed && time() - $parsed[0] <= self::OTP_TTL;
-	}
-
-	/**
-	 * Whether the user has used up their attempt budget and is permanently locked out.
-	 *
-	 * The lockout only lifts when the user is verified another way (e.g. password reset) or the
-	 * store owner verifies them from the admin — both of which clear the counter.
-	 *
-	 * @since 11.0.0
-	 *
-	 * @param int $user_id WordPress user ID.
-	 * @return bool
-	 */
-	public function is_locked_out( int $user_id ): bool {
-		$remaining = $this->attempts_remaining( $user_id );
-
-		return null !== $remaining && $remaining <= 0;
-	}
-
-	/**
-	 * Return the number of guesses the user has left, or null if the flow has not started (no counter
-	 * row yet).
-	 *
-	 * @param int $user_id WordPress user ID.
-	 * @return int|null
-	 */
-	private function attempts_remaining( int $user_id ): ?int {
-		$raw = (string) Users::get_site_user_meta( $user_id, self::ATTEMPTS_META );
-
-		return '' === $raw ? null : (int) $raw;
-	}
-
-	/**
-	 * Generate a zero-padded 6-digit numeric code.
-	 *
-	 * @return string
-	 */
-	private function generate_code(): string {
-		return str_pad( (string) wp_rand( 0, 999999 ), 6, '0', STR_PAD_LEFT );
+		return null !== $parsed && time() - $parsed[0] <= self::KEY_TTL;
 	}

 	/**
-	 * Parse the stored code tuple into its timestamp, hash, email-hash, and attempt parts.
+	 * Parse the stored verification token into its timestamp, key-hash, and email-hash parts.
 	 *
-	 * The tuple is persisted as "{timestamp}:{code_hash}:{email_hash}:{attempts}"; this is the
-	 * single place that knows that format.
+	 * The token is persisted as "{timestamp}:{key_hash}:{email_hash}"; this is the single place that
+	 * knows that format.
 	 *
 	 * @param int $user_id WordPress user ID.
-	 * @return array{0: int, 1: string, 2: string, 3: int}|null The tuple, or null when none is stored.
+	 * @return array{0: int, 1: string, 2: string}|null The triplet, or null when none is stored.
 	 */
 	private function parse_stored_key( int $user_id ): ?array {
 		$stored = (string) Users::get_site_user_meta( $user_id, self::KEY_META );
@@ -386,26 +266,26 @@ class EmailVerificationService {
 			return null;
 		}

-		$parts      = explode( ':', $stored, 4 );
+		$parts      = explode( ':', $stored, 3 );
 		$timestamp  = (int) ( $parts[0] ?? 0 );
 		$hash       = (string) ( $parts[1] ?? '' );
 		$email_hash = (string) ( $parts[2] ?? '' );
-		$attempts   = (int) ( $parts[3] ?? 0 );

 		if ( '' === $hash || '' === $email_hash || 0 === $timestamp ) {
 			return null;
 		}

-		return array( $timestamp, $hash, $email_hash, $attempts );
+		return array( $timestamp, $hash, $email_hash );
 	}

 	/**
-	 * Return the number of seconds elapsed since the last code was issued, or null if none exists.
+	 * Return the number of seconds elapsed since the last verification key was issued, or null if none
+	 * exists.
 	 *
 	 * @since 11.0.0
 	 *
 	 * @param int $user_id WordPress user ID.
-	 * @return int|null Seconds since the last code was created, or null when none is stored.
+	 * @return int|null Seconds since the last key was created, or null when none is stored.
 	 */
 	public function seconds_since_last_key( int $user_id ): ?int {
 		$parsed = $this->parse_stored_key( $user_id );
diff --git a/plugins/woocommerce/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmail.php b/plugins/woocommerce/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmail.php
index 02e38b960c5..9843d5fe83b 100644
--- a/plugins/woocommerce/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmail.php
+++ b/plugins/woocommerce/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmail.php
@@ -20,11 +20,11 @@ defined( 'ABSPATH' ) || exit;
 class CustomerVerifyEmail extends WC_Email {

 	/**
-	 * One-time verification code included in the email.
+	 * One-time verification URL included in the email.
 	 *
 	 * @var string
 	 */
-	public $verify_code;
+	public $verify_url;

 	/**
 	 * Display name used to greet the customer.
@@ -96,16 +96,16 @@ class CustomerVerifyEmail extends WC_Email {
 	/**
 	 * Trigger the sending of this email.
 	 *
-	 * @param int    $user_id     The user ID to send the email to.
-	 * @param string $verify_code The one-time verification code.
+	 * @param int    $user_id    The user ID to send the email to.
+	 * @param string $verify_url The one-time verification URL.
 	 * @return void
 	 */
-	public function trigger( $user_id, $verify_code = '' ) {
+	public function trigger( $user_id, $verify_url = '' ) {
 		$this->setup_locale();

-		if ( $user_id && $verify_code ) {
+		if ( $user_id && $verify_url ) {
 			$this->object            = new WP_User( $user_id );
-			$this->verify_code       = $verify_code;
+			$this->verify_url        = $verify_url;
 			$this->recipient         = wp_unslash( $this->object->user_email );
 			$customer                = new WC_Customer( $user_id );
 			$first_name              = ! empty( $customer->get_billing_first_name() ) ? $customer->get_billing_first_name() : $this->object->first_name;
@@ -130,7 +130,7 @@ class CustomerVerifyEmail extends WC_Email {
 				'additional_content' => $this->get_additional_content(),
 				'user_display_name'  => $this->user_display_name,
 				'user_email'         => $this->object instanceof WP_User ? $this->object->user_email : '',
-				'verify_code'        => $this->verify_code,
+				'verify_url'         => $this->verify_url,
 				'blogname'           => $this->get_blogname(),
 				'sent_to_admin'      => false,
 				'plain_text'         => false,
@@ -152,7 +152,7 @@ class CustomerVerifyEmail extends WC_Email {
 				'additional_content' => $this->get_additional_content(),
 				'user_display_name'  => $this->user_display_name,
 				'user_email'         => $this->object instanceof WP_User ? $this->object->user_email : '',
-				'verify_code'        => $this->verify_code,
+				'verify_url'         => $this->verify_url,
 				'blogname'           => $this->get_blogname(),
 				'sent_to_admin'      => false,
 				'plain_text'         => true,
@@ -172,7 +172,7 @@ class CustomerVerifyEmail extends WC_Email {
 			array(
 				'user_display_name' => $this->user_display_name,
 				'user_email'        => $this->object instanceof WP_User ? $this->object->user_email : '',
-				'verify_code'       => $this->verify_code,
+				'verify_url'        => $this->verify_url,
 				'sent_to_admin'     => false,
 				'plain_text'        => false,
 				'email'             => $this,
diff --git a/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationController.php b/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationController.php
index 90242cc6552..fae1a841c7d 100644
--- a/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationController.php
+++ b/plugins/woocommerce/src/Internal/CustomerEmailVerification/VerificationController.php
@@ -4,52 +4,69 @@ declare( strict_types=1 );
 namespace Automattic\WooCommerce\Internal\CustomerEmailVerification;

 /**
- * Drives the customer email-verification UI on My Account and processes its requests.
+ * Drives the customer email-verification UI on My Account and processes its verify-links.
  *
- * Verification uses a short-lived 6-digit code (OTP) the customer types into a form on a My Account
- * sub-page (/orders/verify/) — the same logged-in session that requested it. There is no
- * verification link, so an email client or security scanner that prefetches URLs cannot complete
- * verification.
+ * Verification uses a one-time link emailed to the customer. Opening the link verifies the address
+ * directly — but ONLY when the request is authenticated as the link's target user. The login gate is
+ * the control, not the HTTP verb.
+ *
+ * This intentionally mirrors WordPress core's own email-change confirmation links, which likewise
+ * complete a sensitive change on an authenticated GET carrying an unguessable secret — no interstitial,
+ * form, or nonce — relying on the auth gate plus the secret:
+ *
+ * - Administration email change: wp-admin/options.php (`adminhash`) —
+ *   https://github.com/WordPress/WordPress/blob/master/wp-admin/options.php
+ * - Profile email change: wp-admin/user-edit.php (`newuseremail`) —
+ *   https://github.com/WordPress/WordPress/blob/master/wp-admin/user-edit.php
+ *
+ * What makes the link safe:
+ *
+ * - A prefetch (email client or security scanner) is never logged in as the customer, so it can never
+ *   reach the verify branch — it only ever sees the My Account login. It cannot consume the key.
+ * - The key is a one-time, time-limited secret bound by hash to the account's current email, so it is
+ *   inert without an authenticated session as the target: a leaked key cannot be spent by anyone who is
+ *   not already that user (which is also why, like core, it is safe to carry the key in the URL).
+ * - An attacker who registered an account with someone else's email can't read the victim's inbox, so
+ *   never receives the link; and the victim can only reach a logged-in-as-target state by resetting the
+ *   password, which invalidates the attacker's session.
+ *
+ * No auth cookie is ever minted by the link (that would be exploitable as login CSRF): a logged-out
+ * visitor is shown the My Account login on the link itself, and signing in returns them to the link
+ * (the verify params are preserved in its URL) to complete it as themselves.
  *
  * @since 11.0.0
  */
 class VerificationController {

 	/**
-	 * Nonce action used to protect the send-code request.
+	 * Nonce action used to protect the send-verification request.
 	 */
 	private const SEND_NONCE_ACTION = 'woocommerce-send-verification-email';

 	/**
-	 * Nonce action used to protect the code submission.
-	 */
-	private const VERIFY_NONCE_ACTION = 'woocommerce-verify-email';
-
-	/**
-	 * Query param used to trigger the send-code request.
+	 * Query param used to trigger the send-verification request.
 	 */
 	private const SEND_PARAM = 'wc_send_verification';

 	/**
-	 * Form field carrying the submitted code.
+	 * Query param carrying the plaintext verification key.
 	 */
-	private const CODE_FIELD = 'wc_verify_email_code';
+	private const KEY_PARAM = 'wc_verify_email_key';

 	/**
-	 * Hidden form field marking a code submission.
+	 * Query param carrying the target user ID.
 	 */
-	private const SUBMIT_FIELD = 'wc_verify_email_submit';
+	private const USER_PARAM = 'wc_verify_email_user';

 	/**
-	 * Minimum seconds between code sends (rate limit).
+	 * Query param carrying a one-off result code to print as a notice on the account page.
 	 */
-	private const SEND_RATE_LIMIT = 60;
+	private const NOTICE_PARAM = 'wc_verify_notice';

 	/**
-	 * Reserved value of the orders endpoint that renders the code-entry sub-page (/orders/verify/).
-	 * Reusing the orders endpoint's existing value segment avoids registering a new rewrite endpoint.
+	 * Minimum seconds between sends (rate limit).
 	 */
-	private const VERIFY_VALUE = 'verify';
+	private const SEND_RATE_LIMIT = 60;

 	/**
 	 * Verification service.
@@ -63,10 +80,8 @@ class VerificationController {
 	 */
 	public function __construct() {
 		add_action( 'template_redirect', array( $this, 'maybe_process_request' ) );
+		add_action( 'woocommerce_before_account_orders', array( $this, 'print_result_notice' ), 5 );
 		add_action( 'woocommerce_before_account_orders', array( $this, 'render_prompt' ) );
-		// Render the form on the /orders/verify/ sub-page (priority 1, before the default orders list).
-		add_action( 'woocommerce_account_orders_endpoint', array( $this, 'maybe_render_on_orders_endpoint' ), 1 );
-		add_filter( 'woocommerce_endpoint_orders_title', array( $this, 'maybe_filter_orders_title' ) );
 	}

 	/**
@@ -80,7 +95,11 @@ class VerificationController {
 	}

 	/**
-	 * Route an incoming request: either a send-code request or a code submission.
+	 * Route an incoming request: a send request or an opened verify-link.
+	 *
+	 * Opening the emailed link is a GET, which email clients and security scanners routinely prefetch.
+	 * Verification is gated on authentication ({@see self::handle_verify_link()}), so a prefetch — always
+	 * logged out — only ever reaches the My Account login and can never consume the key.
 	 *
 	 * @since 11.0.0
 	 */
@@ -90,95 +109,77 @@ class VerificationController {
 			return;
 		}

-		if ( $this->is_code_submission() ) {
-			$this->handle_code_submission();
-			return;
-		}
-
-		// On the /orders/verify/ sub-page, bounce anyone with nothing to verify back to orders. Done
-		// here, before output, because the rendering callback can't safely redirect.
-		if ( self::VERIFY_VALUE === get_query_var( 'orders' ) && ! $this->should_show_prompt() ) {
-			wp_safe_redirect( wc_get_account_endpoint_url( 'orders' ) );
-			exit;
+		// No nonce on the verify-link: like WordPress core's email-change confirmation links, the
+		// unguessable one-time key is the CSRF defence and the login gate is the authority.
+		// phpcs:disable WordPress.Security.NonceVerification.Recommended
+		if ( isset( $_GET[ self::KEY_PARAM ], $_GET[ self::USER_PARAM ] ) ) {
+			$this->handle_verify_link(
+				absint( wp_unslash( $_GET[ self::USER_PARAM ] ) ),
+				sanitize_text_field( wp_unslash( $_GET[ self::KEY_PARAM ] ) )
+			);
 		}
+		// phpcs:enable WordPress.Security.NonceVerification.Recommended
 	}

 	/**
-	 * Whether the current request is a submission of the code form.
+	 * Verify the address from an opened verify-link — gated on being logged in as the link's target user.
 	 *
-	 * @return bool
-	 */
-	private function is_code_submission(): bool {
-		$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET';
-
-		// Nonce is verified in handle_code_submission(); this only routes the request.
-		return 'POST' === $method && isset( $_POST[ self::SUBMIT_FIELD ] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
-	}
-
-	/**
-	 * Verify the submitted code and record the outcome.
+	 * The login gate is the control: verification, and key consumption, happen ONLY on the path where the
+	 * request is authenticated as $user_id. A prefetch or any logged-out visit is shown the My Account
+	 * login and never touches the key; a visitor logged in as a different account is refused without
+	 * consuming it. This is the same shape as WordPress core's email-change confirmation links
+	 * (wp-admin/options.php `adminhash`, wp-admin/user-edit.php `newuseremail`): a sensitive change
+	 * completed on an authenticated GET carrying an unguessable secret.
 	 *
 	 * @since 11.0.0
+	 *
+	 * @param int    $user_id Target user ID from the link.
+	 * @param string $key     Plaintext verification key from the link.
+	 * @return void
 	 */
-	private function handle_code_submission(): void {
-		$nonce = isset( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : '';
+	private function handle_verify_link( int $user_id, string $key ): void {
+		// The key rides in the URL, so keep this response off caches and out of third-party Referer
+		// headers (the logged-out branch renders a themed front-end page that may load such assets).
+		nocache_headers();
+		if ( ! headers_sent() ) {
+			header( 'Referrer-Policy: no-referrer' );
+		}

-		if ( ! wp_verify_nonce( $nonce, self::VERIFY_NONCE_ACTION ) ) {
-			wc_add_notice( __( 'Invalid request. Please try again.', 'woocommerce' ), 'error' );
-			wp_safe_redirect( wc_get_account_endpoint_url( 'orders' ) );
-			exit;
+		$current_user_id = get_current_user_id();
+
+		// Logged out (including any prefetcher): never verify, never consume the key. Render the My
+		// Account login; the verify params stay in the URL so signing in returns here to complete it.
+		if ( ! $current_user_id ) {
+			wc_add_notice( __( 'You need to be logged in to confirm your email address.', 'woocommerce' ), 'notice' );
+			return;
 		}

-		$user_id = get_current_user_id();
+		// Logged in as someone else: refuse rather than silently switching accounts. The key is untouched.
+		if ( $current_user_id !== $user_id ) {
+			$this->redirect_with_result( 'mismatch' );
+		}

-		if ( ! $user_id ) {
-			wp_safe_redirect( wc_get_account_endpoint_url( 'orders' ) );
-			exit;
+		// Authenticated as the target — the only path that consumes the key and verifies.
+		if ( $this->process_verification( $user_id, $key ) ) {
+			$this->redirect_with_result( 'confirmed' );
 		}

-		// Keep digits only so spaces or stray characters in the pasted code don't cause a false mismatch.
-		$code = preg_replace( '/\D/', '', isset( $_POST[ self::CODE_FIELD ] ) ? sanitize_text_field( wp_unslash( $_POST[ self::CODE_FIELD ] ) ) : '' );
-
-		$result = $this->service->verify_code( $user_id, (string) $code );
-
-		switch ( $result ) {
-			case EmailVerificationService::RESULT_OK:
-				wc_add_notice( __( 'Your email address has been confirmed.', 'woocommerce' ) );
-				break;
-			case EmailVerificationService::RESULT_WRONG:
-				wc_add_notice( __( 'That code is incorrect. Please try again.', 'woocommerce' ), 'error' );
-				break;
-			case EmailVerificationService::RESULT_BURNED:
-				wc_add_notice( __( 'That code is no longer valid. Please request a new one.', 'woocommerce' ), 'error' );
-				break;
-			case EmailVerificationService::RESULT_EXPIRED:
-				wc_add_notice( __( 'That code has expired. Please request a new one.', 'woocommerce' ), 'error' );
-				break;
-			case EmailVerificationService::RESULT_LOCKED:
-				wc_add_notice( $this->locked_message(), 'error' );
-				break;
-			default:
-				// RESULT_NONE: no pending code. If a prior submission (e.g. a double click) already
-				// verified the address, show success rather than a stale "request a new code" error.
-				if ( $this->service->is_verified( $user_id ) ) {
-					wc_add_notice( __( 'Your email address has been confirmed.', 'woocommerce' ) );
-				} else {
-					wc_add_notice( __( 'Please request a new code to confirm your email address.', 'woocommerce' ), 'error' );
-				}
-				break;
+		// Already verified (e.g. the link re-opened after the key was spent): land on Orders quietly,
+		// without repeating the success notice for a confirmation that already happened.
+		if ( $this->service->is_verified( $user_id ) ) {
+			wp_safe_redirect( wc_get_account_endpoint_url( 'orders' ) );
+			exit;
 		}

-		// Verified customers go to their orders; everyone else stays on the form to try again.
-		$verified = EmailVerificationService::RESULT_OK === $result || $this->service->is_verified( $user_id );
-		wp_safe_redirect( $verified ? wc_get_account_endpoint_url( 'orders' ) : $this->verify_url() );
-		exit;
+		// Authenticated as the target, but the key is invalid or expired and they are not verified.
+		$this->redirect_with_result( 'expired' );
 	}

 	/**
-	 * Handle a request to send (or resend) a verification code, triggered by the My Account prompt.
+	 * Handle a request to send (or resend) the verification email, triggered by the My Account prompt.
 	 *
-	 * Verifies the nonce, refuses to mint for a locked-out user, applies a resend rate-limit, then
-	 * dispatches the code and redirects back to the orders panel (where the entry form is shown).
+	 * Verifies the nonce, applies a rate-limit (does not re-send within the window), dispatches the
+	 * email, and redirects to the orders section, where the prompt points the customer to their inbox.
 	 *
 	 * @since 11.0.0
 	 */
@@ -192,30 +193,18 @@ class VerificationController {
 		$nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';

 		if ( ! wp_verify_nonce( $nonce, self::SEND_NONCE_ACTION ) ) {
-			wc_add_notice( __( 'Invalid request. Please try again.', 'woocommerce' ), 'error' );
-			wp_safe_redirect( wc_get_account_endpoint_url( 'orders' ) );
-			exit;
-		}
-
-		// A locked-out customer can only be verified another way (e.g. by the store owner); never mint.
-		if ( $this->service->is_locked_out( $user_id ) ) {
-			wp_safe_redirect( $this->verify_url() );
-			exit;
+			$this->redirect_with_result( 'invalid' );
 		}

-		// Only send a fresh code once the last one is outside the rate-limit window; otherwise the
-		// existing code still stands and the entry form continues to point the customer to their inbox.
+		// Only send a fresh link once the last one is outside the rate-limit window; otherwise the
+		// existing link still stands and the prompt continues to point the customer to their inbox.
 		$seconds_since = $this->service->seconds_since_last_key( $user_id );
 		if ( null === $seconds_since || $seconds_since >= self::SEND_RATE_LIMIT ) {
 			$this->send_verification_email( $user_id );
-			wc_add_notice( __( 'A code has been sent to your email address. Please check your inbox.', 'woocommerce' ), 'success' );
-		} else {
-			wc_add_notice( __( 'A code was sent recently. Please check your inbox, or wait a moment before requesting a new one.', 'woocommerce' ), 'notice' );
+			$this->redirect_with_result( 'sent' );
 		}

-		// Land on the /orders/verify/ sub-page, where the entry form is shown.
-		wp_safe_redirect( $this->verify_url() );
-		exit;
+		$this->redirect_with_result( 'throttled' );
 	}

 	/**
@@ -253,9 +242,8 @@ class VerificationController {
 	/**
 	 * Render the verification prompt notice on the My Account "Orders" panel.
 	 *
-	 * The notice initiates the flow but does not host the form: a permanent-lockout message, a
-	 * pointer to the /orders/verify/ sub-page when a code is already pending, or the "send code" call
-	 * to action (which sends a code and redirects to the /orders/verify/ sub-page).
+	 * Within the rate-limit window a link was sent recently, so the prompt points the customer to their
+	 * inbox and offers no immediate resend; otherwise it carries the "confirm email" call to action.
 	 *
 	 * @internal
 	 * @since 11.0.0
@@ -265,114 +253,22 @@ class VerificationController {
 			return;
 		}

-		$user_id = get_current_user_id();
-
-		if ( $this->service->is_locked_out( $user_id ) ) {
-			$html = $this->get_locked_html();
-		} elseif ( $this->service->has_pending_code( $user_id ) ) {
-			$html = $this->get_pending_notice_html();
-		} else {
-			$html = $this->get_send_cta_html();
-		}
-
-		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- each helper escapes every interpolated value.
-		echo $html;
-	}
+		$user_id       = get_current_user_id();
+		$seconds_since = $this->service->seconds_since_last_key( $user_id );

-	/**
-	 * Render the verification sub-page content: the code-entry form (or the lockout / send states).
-	 *
-	 * Reached from the orders prompt via {@see self::maybe_render_on_orders_endpoint()}. Access is
-	 * gated in {@see self::maybe_process_request()}, which redirects anyone with nothing to verify
-	 * back to orders before output.
-	 *
-	 * @internal
-	 * @since 11.0.0
-	 */
-	public function render_endpoint_content(): void {
-		if ( ! $this->should_show_prompt() ) {
+		if ( null !== $seconds_since && $seconds_since <= self::SEND_RATE_LIMIT ) {
+			// A just-sent/throttled result notice (from the redirect) already points to the inbox this
+			// page load, so don't print a second "check your inbox" alongside it.
+			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only, no state change.
+			if ( ! isset( $_GET[ self::NOTICE_PARAM ] ) ) {
+				wc_print_notice(
+					esc_html__( 'Confirm your email address to check for past orders. A confirmation link was sent recently — please check your inbox.', 'woocommerce' ),
+					'notice'
+				);
+			}
 			return;
 		}

-		$user_id = get_current_user_id();
-
-		if ( $this->service->is_locked_out( $user_id ) ) {
-			$html = $this->get_locked_html();
-		} elseif ( $this->service->has_pending_code( $user_id ) ) {
-			$this->enqueue_form_assets();
-			$html = $this->get_code_form_html();
-		} else {
-			$html = $this->get_send_cta_html();
-		}
-
-		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- each helper escapes every interpolated value.
-		echo $html;
-	}
-
-	/**
-	 * Render the verification UI on the /orders/verify/ sub-page.
-	 *
-	 * Hooked early on the orders endpoint. When the orders value is the reserved "verify" token, this
-	 * renders the verification UI and removes the default orders-list output so only the form shows;
-	 * for any other value (a page number, or none) it is a no-op and the orders list renders normally.
-	 *
-	 * @internal
-	 * @param string $value The orders endpoint value (a page number, or the verify token).
-	 * @return void
-	 */
-	public function maybe_render_on_orders_endpoint( $value ): void {
-		if ( self::VERIFY_VALUE !== $value ) {
-			return;
-		}
-
-		remove_action( 'woocommerce_account_orders_endpoint', 'woocommerce_account_orders' );
-		$this->render_endpoint_content();
-	}
-
-	/**
-	 * Force a clean "Orders" title on the /orders/verify/ sub-page.
-	 *
-	 * The sub-page reuses the orders endpoint's value slot, so WooCommerce would otherwise render the
-	 * title as "Orders (Page 0)". Other orders pages keep their default title.
-	 *
-	 * @internal
-	 * @param string $title Default orders endpoint title.
-	 * @return string
-	 */
-	public function maybe_filter_orders_title( $title ): string {
-		if ( self::VERIFY_VALUE === get_query_var( 'orders' ) ) {
-			return __( 'Orders', 'woocommerce' );
-		}
-		return $title;
-	}
-
-	/**
-	 * URL of the /orders/verify/ sub-page that hosts the code-entry form.
-	 *
-	 * Reuses the orders endpoint's existing value segment, so no new rewrite rule (and no flush) is
-	 * needed for the URL to resolve.
-	 *
-	 * @return string
-	 */
-	private function verify_url(): string {
-		return wc_get_endpoint_url( 'orders', self::VERIFY_VALUE, wc_get_page_permalink( 'myaccount' ) );
-	}
-
-	/**
-	 * Build the permanent-lockout message block.
-	 *
-	 * @return string Fully escaped HTML.
-	 */
-	private function get_locked_html(): string {
-		return wc_print_notice( esc_html( $this->locked_message() ), 'error', array(), true );
-	}
-
-	/**
-	 * Build the "send confirmation code" call-to-action block.
-	 *
-	 * @return string Fully escaped HTML.
-	 */
-	private function get_send_cta_html(): string {
 		$send_url = wp_nonce_url(
 			add_query_arg( self::SEND_PARAM, '1', wc_get_account_endpoint_url( 'orders' ) ),
 			self::SEND_NONCE_ACTION
@@ -382,208 +278,89 @@ class VerificationController {
 			'<a href="%2$s" class="button wc-forward">%3$s</a> %1$s',
 			esc_html__( 'Confirm your email address to check for past orders and link them to your account.', 'woocommerce' ),
 			esc_url( $send_url ),
-			esc_html__( 'Send confirmation code', 'woocommerce' )
+			esc_html__( 'Confirm email address', 'woocommerce' )
 		);

-		return wc_print_notice( $notice, 'notice', array(), true );
+		wc_print_notice( $notice, 'notice' );
 	}

 	/**
-	 * Build the orders-panel notice shown when a code is already pending: a pointer to the
-	 * /orders/verify/ sub-page where the customer enters it.
+	 * Print the one-off result notice carried by the {@see self::NOTICE_PARAM} query arg, if any.
 	 *
-	 * @return string Fully escaped HTML.
-	 */
-	private function get_pending_notice_html(): string {
-		$notice = sprintf(
-			'<a href="%2$s" class="button wc-forward">%3$s</a> %1$s',
-			esc_html__( 'We emailed you a confirmation code to confirm your email address.', 'woocommerce' ),
-			esc_url( $this->verify_url() ),
-			esc_html__( 'Enter your code', 'woocommerce' )
-		);
-
-		return wc_print_notice( $notice, 'notice', array(), true );
-	}
-
-	/**
-	 * Build the code-entry form block.
+	 * Send/confirm actions redirect here with a result code rather than queuing a session notice, so the
+	 * page shows exactly the current request's outcome — re-running an action can't stack notices.
 	 *
-	 * @return string Fully escaped HTML.
+	 * @internal
+	 * @since 11.0.0
 	 */
-	private function get_code_form_html(): string {
-		$user       = wp_get_current_user();
-		$resend_url = wp_nonce_url(
-			add_query_arg( self::SEND_PARAM, '1', wc_get_account_endpoint_url( 'orders' ) ),
-			self::SEND_NONCE_ACTION
-		);
+	public function print_result_notice(): void {
+		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only, no state change.
+		$code   = isset( $_GET[ self::NOTICE_PARAM ] ) ? sanitize_key( wp_unslash( $_GET[ self::NOTICE_PARAM ] ) ) : '';
+		$notice = $this->result_notice( $code );

-		$template = '
-<form method="post" action="%1$s" class="woocommerce-verify-email-form">
-<p>%2$s</p>
-<div class="woocommerce-otp-input-wrapper">
-<input type="text" name="%3$s" aria-label="%4$s" placeholder="······" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}" maxlength="6" required class="input-text woocommerce-otp-input" />
-<button type="submit" class="wp-element-button button">%5$s</button>
-</div>
-<p class="woocommerce-otp-resend">%6$s <a href="%7$s" class="wc-forward">%8$s</a></p>
-<input type="hidden" name="%9$s" value="1" />
-%10$s
-</form>
-';
-
-		return sprintf(
-			$template,
-			esc_url( $this->verify_url() ),
-			// translators: %s: the customer's email address.
-			sprintf( esc_html__( 'Enter the 6-digit code that was sent to %s within 10 minutes to confirm your email address.', 'woocommerce' ), '<b>' . esc_html( $user->user_email ) . '</b>' ),
-			esc_attr( self::CODE_FIELD ),
-			esc_attr__( 'Verification code', 'woocommerce' ),
-			esc_html__( 'Confirm email', 'woocommerce' ),
-			esc_html__( 'Didn\'t receive the code?', 'woocommerce' ),
-			esc_url( $resend_url ),
-			esc_html__( 'Resend a new one', 'woocommerce' ),
-			esc_attr( self::SUBMIT_FIELD ),
-			wp_nonce_field( self::VERIFY_NONCE_ACTION, '_wpnonce', true, false )
-		);
+		if ( null !== $notice ) {
+			wc_print_notice( esc_html( $notice[0] ), $notice[1] );
+		}
 	}

 	/**
-	 * Enqueue the code-entry form's inline CSS and progressive-enhancement JS.
+	 * Map a redirect result code to its [ message, notice type ], or null for an unknown code.
 	 *
-	 * Both attach to an empty-source handle and print only when the form renders, so neither ships
-	 * site-wide and no build step is needed. The form is fully usable without the JS: native
-	 * constraints (`required`, a 6-digit `pattern`) gate submission and `:invalid` styling dims the
-	 * button until a complete code is entered. The JS only adds niceties — it trims pasted input,
-	 * auto-submits a complete pasted code, and guards against a double submit.
-	 *
-	 * @return void
+	 * @param string $code Result code from a send/confirm redirect.
+	 * @return array{0: string, 1: string}|null
 	 */
-	private function enqueue_form_assets(): void {
-		$handle = 'wc-customer-email-verification';
-
-		// Empty-source handle carrying only the inline CSS/JS; re-registering is a no-op if it exists.
-		wp_register_style( $handle, false, array(), \WC_VERSION );
-		wp_enqueue_style( $handle );
-		wp_add_inline_style( $handle, $this->get_form_styles() );
-
-		wp_register_script( $handle, false, array(), \WC_VERSION, true );
-		wp_enqueue_script( $handle );
-		wp_add_inline_script( $handle, $this->get_form_script() );
+	private function result_notice( string $code ): ?array {
+		switch ( $code ) {
+			case 'sent':
+				return array( __( 'A confirmation link has been sent to your email address. Please check your inbox.', 'woocommerce' ), 'success' );
+			case 'throttled':
+				return array( __( 'A confirmation link was sent recently. Please check your inbox, or wait a moment before requesting a new one.', 'woocommerce' ), 'notice' );
+			case 'confirmed':
+				return array( __( 'Your email address has been confirmed.', 'woocommerce' ), 'success' );
+			case 'expired':
+				return array( __( 'This confirmation link is invalid or has expired. Please request a new one.', 'woocommerce' ), 'error' );
+			case 'mismatch':
+				return array( __( 'Unable to confirm this email while you are logged in to a different account. Please log out and open the link again.', 'woocommerce' ), 'error' );
+			case 'invalid':
+				return array( __( 'Invalid request. Please try again.', 'woocommerce' ), 'error' );
+			default:
+				return null;
+		}
 	}

 	/**
-	 * The inline CSS for the code-entry form.
+	 * Redirect to the orders section carrying a one-off result code, then exit.
 	 *
-	 * Printed only on the verification sub-page (via {@see self::enqueue_form_assets()}) rather than in
-	 * the site-wide stylesheet. Selectors must stay in sync with {@see self::get_code_form_html()}. The
-	 * submit button is dimmed purely via CSS: `:invalid` (the input is `required` with a 6-digit
-	 * `pattern`) while the code is incomplete, and `.is-submitting` while the POST is in flight.
-	 *
-	 * @return string
+	 * @param string $code Result code understood by {@see self::result_notice()}.
+	 * @return never
 	 */
-	private function get_form_styles(): string {
-		return <<<'CSS'
-.woocommerce-verify-email-form {
-	text-align: center;
-	text-wrap: balance;
-	border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
-	padding: 16px;
-	margin: 0 0 16px;
-	border-radius: 4px;
-}
-.woocommerce-verify-email-form .woocommerce-otp-input-wrapper {
-	display: inline-flex;
-	align-items: stretch;
-	justify-content: center;
-	flex-direction: column;
-	gap: 8px;
-	margin: 16px 0;
-}
-.woocommerce-verify-email-form .woocommerce-otp-input.input-text {
-	font-size: 32px;
-	font-weight: 700;
-	letter-spacing: 0.4em;
-	text-indent: 0.4em;
-	padding: 0.2em 0.4em;
-	font-variant-numeric: tabular-nums;
-	text-align: center;
-	line-height: 1;
-	color: black;
-	width: 6.8em;
-	font-family: monospace;
-	flex: 0;
-}
-.woocommerce-verify-email-form .woocommerce-otp-input-wrapper .button.wp-element-button {
-	margin: 0;
-}
-.woocommerce-verify-email-form .woocommerce-otp-input:invalid + .button.wp-element-button,
-.woocommerce-verify-email-form.is-submitting .button.wp-element-button {
-	cursor: not-allowed;
-	opacity: 0.5;
-}
-.woocommerce-verify-email-form .woocommerce-otp-resend {
-	font-size: var(--wp--preset--font-size--x-small, calc(var(--wp--preset--font-size--small, 14px) * 0.875));
-}
-CSS;
+	private function redirect_with_result( string $code ): void {
+		wp_safe_redirect( add_query_arg( self::NOTICE_PARAM, $code, wc_get_account_endpoint_url( 'orders' ) ) );
+		exit;
 	}

 	/**
-	 * The inline JavaScript that enhances the code-entry form.
+	 * Validate a key and verify the user.
 	 *
-	 * Pure progressive enhancement — the form works without it. Selectors must stay in sync with
-	 * {@see self::get_code_form_html()}: the `.woocommerce-verify-email-form` form and its
-	 * `wc_verify_email_code` input. The submit button's dimmed state is owned by CSS (`:invalid`), not
-	 * here; this only trims input, auto-submits a complete pasted code, and blocks a double submit.
+	 * @since 11.0.0
 	 *
-	 * @return string
+	 * @param int    $user_id User ID.
+	 * @param string $key     Plaintext verification key.
+	 * @return bool True when verification succeeded.
 	 */
-	private function get_form_script(): string {
-		return <<<'JS'
-( function () {
-	var form = document.querySelector( '.woocommerce-verify-email-form' );
-	if ( ! form ) { return; }
-	var input = form.querySelector( 'input[name="wc_verify_email_code"]' );
-	if ( ! input ) { return; }
-
-	input.focus();
-
-	input.addEventListener( 'input', function ( event ) {
-		// Trim whitespace and any non-digits (e.g. from a pasted code), capped at six digits.
-		var digits = input.value.replace( /\D/g, '' ).slice( 0, 6 );
-		if ( digits !== input.value ) { input.value = digits; }
-
-		// Auto-submit only a complete code that was pasted or dropped in — never while typing, so a
-		// mistyped digit can't submit by accident. requestSubmit() still runs native validation.
-		var inserted = event && event.inputType;
-		var pasted = 'insertFromPaste' === inserted || 'insertFromDrop' === inserted;
-		if ( pasted && 6 === digits.length && form.requestSubmit ) {
-			form.requestSubmit();
+	public function process_verification( int $user_id, string $key ): bool {
+		if ( ! $user_id || '' === $key ) {
+			return false;
 		}
-	} );
-
-	form.addEventListener( 'submit', function ( event ) {
-		// Block a double submit; .is-submitting also dims the button while the POST is in flight.
-		if ( form.classList.contains( 'is-submitting' ) ) {
-			event.preventDefault();
-			return;
+		if ( ! $this->service->check_verification_key( $user_id, $key ) ) {
+			return false;
 		}
-		form.classList.add( 'is-submitting' );
-		input.readOnly = true;
-	} );
-}() );
-JS;
-	}
-
-	/**
-	 * The permanent-lockout message, shared by the rendered block and the submission notice.
-	 *
-	 * @return string
-	 */
-	private function locked_message(): string {
-		return __( 'Too many incorrect attempts. Please contact the store owner to confirm your email address.', 'woocommerce' );
+		$this->service->mark_verified( $user_id );
+		return true;
 	}

 	/**
-	 * Send (or resend) a verification code to a user.
+	 * Send (or resend) a verification email to a user.
 	 *
 	 * @since 11.0.0
 	 *
@@ -595,18 +372,18 @@ JS;
 			return;
 		}

-		$code = $this->service->create_code( $user_id );
+		$verify_url = $this->service->build_verification_url( $user_id );

 		WC()->mailer();

 		/**
 		 * Triggers sending of the customer email-verification email.
 		 *
-		 * @param int    $user_id     The WordPress user ID of the customer.
-		 * @param string $verify_code The one-time numeric code to include in the email.
+		 * @param int    $user_id    The WordPress user ID of the customer.
+		 * @param string $verify_url The one-time verification URL to include in the email.
 		 *
 		 * @since 11.0.0
 		 */
-		do_action( 'woocommerce_customer_verify_email_notification', $user_id, $code );
+		do_action( 'woocommerce_customer_verify_email_notification', $user_id, $verify_url );
 	}
 }
diff --git a/plugins/woocommerce/templates/emails/block/general-block-email.php b/plugins/woocommerce/templates/emails/block/general-block-email.php
index dd3e8f84aa9..f642eabdf7e 100644
--- a/plugins/woocommerce/templates/emails/block/general-block-email.php
+++ b/plugins/woocommerce/templates/emails/block/general-block-email.php
@@ -91,16 +91,14 @@ if ( 'customer_reset_password' === $email->id && isset( $reset_key, $user_id ) )
 	<?php
 endif;

-if ( 'customer_verify_email' === $email->id && $verify_code ) {
-	?>
-	<!-- wp:paragraph -->
-	<p><?php esc_html_e( 'Use this code to confirm your email address:', 'woocommerce' ); ?></p>
-	<!-- /wp:paragraph -->
-
-	<!-- wp:paragraph -->
-	<p style="font-size: 32px; font-weight: 700; letter-spacing: 0.4em; text-align: center;"><?php echo esc_html( $verify_code ); ?></p>
-	<!-- /wp:paragraph -->
-	<?php
+if ( 'customer_verify_email' === $email->id && $verify_url ) {
+	wc_get_template(
+		'emails/email-button.php',
+		array(
+			'url'   => $verify_url,
+			'label' => __( 'Confirm email address', 'woocommerce' ),
+		)
+	);
 }

 /**
diff --git a/plugins/woocommerce/templates/emails/customer-verify-email.php b/plugins/woocommerce/templates/emails/customer-verify-email.php
index d75f40f204a..4e530405763 100644
--- a/plugins/woocommerce/templates/emails/customer-verify-email.php
+++ b/plugins/woocommerce/templates/emails/customer-verify-email.php
@@ -18,7 +18,7 @@
  * @var string    $additional_content Additional content below the body.
  * @var string    $user_display_name  Customer's display name.
  * @var string    $user_email         Email address being confirmed.
- * @var string    $verify_code        One-time verification code.
+ * @var string    $verify_url         One-time verification URL.
  * @var string    $blogname           Site name.
  * @var bool      $sent_to_admin      Whether sent to admin.
  * @var bool      $plain_text         Whether plain-text variant.
@@ -46,8 +46,15 @@ do_action( 'woocommerce_email_header', $email_heading, $email ); ?>
 <p><?php printf( esc_html__( 'Hi %s,', 'woocommerce' ), esc_html( $user_display_name ) ); ?></p>
 <?php /* translators: %s: the customer's email address. */ ?>
 <p><?php printf( esc_html__( "Once you've confirmed that %s is your email address, we'll link any past orders to your account.", 'woocommerce' ), '<b>' . esc_html( $user_email ) . '</b>' ); ?></p>
-<p><?php esc_html_e( 'Use this code to confirm your email address:', 'woocommerce' ); ?></p>
-<p style="font-size: 32px; font-weight: 700; letter-spacing: 0.4em; text-align: center; margin: 24px 0;"><?php echo esc_html( $verify_code ); ?></p>
+<?php
+wc_get_template(
+	'emails/email-button.php',
+	array(
+		'url'   => $verify_url,
+		'label' => __( 'Confirm email address', 'woocommerce' ),
+	)
+);
+?>
 <p><?php esc_html_e( "If you didn't request this email, there's nothing to worry about, and you can safely ignore it.", 'woocommerce' ); ?></p>

 <?php echo $email_improvements_enabled ? '</div>' : ''; ?>
diff --git a/plugins/woocommerce/templates/emails/email-button.php b/plugins/woocommerce/templates/emails/email-button.php
new file mode 100644
index 00000000000..361cd31bd11
--- /dev/null
+++ b/plugins/woocommerce/templates/emails/email-button.php
@@ -0,0 +1,26 @@
+<?php
+/**
+ * Email call-to-action button.
+ *
+ * This template can be overridden by copying it to yourtheme/woocommerce/emails/email-button.php.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates\Emails
+ * @version 11.0.0
+ *
+ * @var string $url   Button destination URL.
+ * @var string $label Button text.
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+// Fall back to the default when the option is set but empty (get_option's default only covers a missing option).
+$wc_button_bg   = get_option( 'woocommerce_email_base_color', '#7f54b3' );
+$wc_button_bg   = $wc_button_bg ? $wc_button_bg : '#7f54b3';
+$wc_button_text = wc_hex_is_light( $wc_button_bg ) ? '#000000' : '#ffffff';
+?>
+<p style="margin: 24px 0;">
+	<a href="<?php echo esc_url( $url ); ?>" style="display:inline-block;padding:16px 32px;background-color:<?php echo esc_attr( $wc_button_bg ); ?>;color:<?php echo esc_attr( $wc_button_text ); ?>;border-radius:4px;font-weight:bold;font-size:15px;text-decoration:none;">
+		<?php echo esc_html( $label ); ?>
+	</a>
+</p>
diff --git a/plugins/woocommerce/templates/emails/plain/customer-verify-email.php b/plugins/woocommerce/templates/emails/plain/customer-verify-email.php
index 7cd83395521..0e2f1e0f8d0 100644
--- a/plugins/woocommerce/templates/emails/plain/customer-verify-email.php
+++ b/plugins/woocommerce/templates/emails/plain/customer-verify-email.php
@@ -18,7 +18,7 @@
  * @var string    $additional_content Additional content below the body.
  * @var string    $user_display_name  Customer's display name.
  * @var string    $user_email         Email address being confirmed.
- * @var string    $verify_code        One-time verification code.
+ * @var string    $verify_url         One-time verification URL.
  * @var string    $blogname           Site name.
  * @var bool      $sent_to_admin      Whether sent to admin.
  * @var bool      $plain_text         Whether plain-text variant.
@@ -36,9 +36,8 @@ echo sprintf( esc_html__( 'Hi %s,', 'woocommerce' ), esc_html( $user_display_nam
 /* translators: %s: the customer's email address. */
 echo sprintf( esc_html__( "Once you've confirmed that %s is your email address, we'll link any past orders to your account.", 'woocommerce' ), esc_html( $user_email ) ) . "\n\n";

-echo esc_html__( 'Use this code to confirm your email address:', 'woocommerce' ) . "\n\n";
 echo "----------------------------------------\n\n";
-echo esc_html( $verify_code ) . "\n\n";
+echo esc_url( $verify_url ) . "\n\n";
 echo "----------------------------------------\n\n";

 echo esc_html__( "If you didn't request this email, there's nothing to worry about, and you can safely ignore it.", 'woocommerce' ) . "\n\n";
diff --git a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php
index c207106d25d..acd61ac0777 100644
--- a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/EmailVerificationServiceTest.php
@@ -8,14 +8,12 @@ use Automattic\WooCommerce\Internal\Utilities\Users;
 use WC_Unit_Test_Case;

 /**
- * Tests for the EmailVerificationService class.
+ * Tests for the EmailVerificationService class (magic-link key API).
  */
 class EmailVerificationServiceTest extends WC_Unit_Test_Case {

 	private const KEY_META = '_wc_email_verification_key';

-	private const ATTEMPTS_META = '_wc_email_verification_attempts';
-
 	/**
 	 * The System Under Test.
 	 *
@@ -64,151 +62,82 @@ class EmailVerificationServiceTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox create_code() returns a fresh, pending six-digit numeric code.
-	 */
-	public function test_create_code_returns_six_digit_code(): void {
-		$user_id = wc_create_new_customer( 'code@example.com', 'codeuser', 'pw' );
-
-		$code = $this->sut->create_code( $user_id );
-
-		$this->assertMatchesRegularExpression( '/^\d{6}$/', $code, 'A code must be six digits' );
-		$this->assertTrue( $this->sut->has_pending_code( $user_id ), 'A freshly minted code should be pending' );
-	}
-
-	/**
-	 * @testdox A correct code verifies the user and consumes the pending code.
-	 */
-	public function test_correct_code_verifies(): void {
-		$user_id = wc_create_new_customer( 'c@example.com', 'userc', 'pw' );
-
-		$code = $this->sut->create_code( $user_id );
-
-		$this->assertSame( EmailVerificationService::RESULT_OK, $this->sut->verify_code( $user_id, $code ) );
-		$this->assertTrue( $this->sut->is_verified( $user_id ), 'User should be verified after a correct code' );
-		$this->assertFalse( $this->sut->has_pending_code( $user_id ), 'The code should be consumed on success' );
-	}
-
-	/**
-	 * @testdox A wrong guess keeps the code pending until its attempts are exhausted, then burns it.
+	 * @testdox create_verification_key() returns a fresh, pending key that passes its own check.
 	 */
-	public function test_wrong_code_burns_after_three_attempts(): void {
-		$user_id = wc_create_new_customer( 'd@example.com', 'userd', 'pw' );
+	public function test_create_key_is_pending_and_checks_out(): void {
+		$user_id = wc_create_new_customer( 'key@example.com', 'keyuser', 'pw' );

-		$code  = $this->sut->create_code( $user_id );
-		$wrong = $this->wrong_code( $code );
+		$key = $this->sut->create_verification_key( $user_id );

-		$this->assertSame( EmailVerificationService::RESULT_WRONG, $this->sut->verify_code( $user_id, $wrong ) );
-		$this->assertSame( EmailVerificationService::RESULT_WRONG, $this->sut->verify_code( $user_id, $wrong ) );
-		$this->assertTrue( $this->sut->has_pending_code( $user_id ), 'Code should still be pending after two wrong guesses' );
-
-		$this->assertSame( EmailVerificationService::RESULT_BURNED, $this->sut->verify_code( $user_id, $wrong ) );
-		$this->assertFalse( $this->sut->has_pending_code( $user_id ), 'Code should be burned after the third wrong guess' );
-		$this->assertFalse( $this->sut->is_verified( $user_id ) );
+		$this->assertNotEmpty( $key, 'A key must be returned' );
+		$this->assertTrue( $this->sut->has_pending_key( $user_id ), 'A freshly minted key should be pending' );
+		$this->assertTrue( $this->sut->check_verification_key( $user_id, $key ), 'The minted key should validate' );
 	}

 	/**
-	 * @testdox Minting a code seeds the attempts counter at the full budget so the compare-and-swap never inserts.
+	 * @testdox A wrong or empty key fails the check.
 	 */
-	public function test_create_code_seeds_attempts_counter(): void {
-		$user_id = wc_create_new_customer( 'init@example.com', 'inituser', 'pw' );
+	public function test_wrong_or_empty_key_fails(): void {
+		$user_id = wc_create_new_customer( 'wrongkey@example.com', 'wrongkeyuser', 'pw' );
+		$this->sut->create_verification_key( $user_id );

-		$this->assertSame( '', (string) Users::get_site_user_meta( $user_id, self::ATTEMPTS_META ), 'No counter before the flow starts' );
-
-		$this->sut->create_code( $user_id );
-
-		$this->assertSame( '10', (string) Users::get_site_user_meta( $user_id, self::ATTEMPTS_META ), 'The first code must seed the counter at the full budget' );
+		$this->assertFalse( $this->sut->check_verification_key( $user_id, 'not-the-key' ), 'A wrong key must fail' );
+		$this->assertFalse( $this->sut->check_verification_key( $user_id, '' ), 'An empty key must fail' );
+		$this->assertFalse( $this->sut->is_verified( $user_id ), 'A failed check must not verify' );
 	}

 	/**
-	 * @testdox Resending a code preserves the remaining-attempts count (it does not lift the lockout budget).
+	 * @testdox The key is single-use: it is consumed once the user is verified.
 	 */
-	public function test_resending_a_code_preserves_remaining_attempts(): void {
-		$user_id = wc_create_new_customer( 'resend@example.com', 'resenduser', 'pw' );
-		$code    = $this->sut->create_code( $user_id );
-
-		$this->sut->verify_code( $user_id, $this->wrong_code( $code ) );
-		$this->assertSame( '9', (string) Users::get_site_user_meta( $user_id, self::ATTEMPTS_META ), 'One wrong guess leaves nine of ten remaining' );
-
-		$this->sut->create_code( $user_id );
-
-		$this->assertSame( '9', (string) Users::get_site_user_meta( $user_id, self::ATTEMPTS_META ), 'Resending must not reset the remaining-attempts count' );
-	}
-
-	/**
-	 * @testdox Ten cumulative wrong guesses lock the user out permanently, even from a correct code.
-	 */
-	public function test_lockout_after_ten_cumulative_failures(): void {
-		$user_id = wc_create_new_customer( 'lock@example.com', 'lockuser', 'pw' );
-
-		$this->assertFalse( $this->sut->is_locked_out( $user_id ) );
-
-		$current  = null;
-		$failures = 0;
-		while ( ! $this->sut->is_locked_out( $user_id ) && $failures < 15 ) {
-			if ( ! $this->sut->has_pending_code( $user_id ) ) {
-				$current = $this->sut->create_code( $user_id );
-			}
-			$this->sut->verify_code( $user_id, $this->wrong_code( (string) $current ) );
-			++$failures;
-		}
-
-		$this->assertTrue( $this->sut->is_locked_out( $user_id ), 'User should be locked out' );
-		$this->assertSame( 10, $failures, 'Lockout should take exactly ten cumulative failures' );
-
-		// Once locked out, even the (now-deleted) correct code path returns LOCKED.
-		$this->assertSame( EmailVerificationService::RESULT_LOCKED, $this->sut->verify_code( $user_id, '123456' ) );
-	}
-
-	/**
-	 * @testdox Verifying a user (e.g. by the store owner) lifts an existing lockout.
-	 */
-	public function test_mark_verified_clears_lockout(): void {
-		$user_id = wc_create_new_customer( 'unlock@example.com', 'unlockuser', 'pw' );
-		$this->force_lockout( $user_id );
+	public function test_key_consumed_on_mark_verified(): void {
+		$user_id = wc_create_new_customer( 'single@example.com', 'singleuser', 'pw' );
+		$key     = $this->sut->create_verification_key( $user_id );

 		$this->sut->mark_verified( $user_id );

-		$this->assertFalse( $this->sut->is_locked_out( $user_id ), 'Marking verified must clear the lockout' );
-		$this->assertTrue( $this->sut->is_verified( $user_id ) );
+		$this->assertFalse( $this->sut->has_pending_key( $user_id ), 'Verifying should consume the pending key' );
+		$this->assertFalse( $this->sut->check_verification_key( $user_id, $key ), 'A consumed key must not re-validate' );
 	}

 	/**
-	 * @testdox Clearing verification also lifts an existing lockout.
+	 * @testdox build_verification_url() carries the user ID and a key that validates.
 	 */
-	public function test_clear_verification_clears_lockout(): void {
-		$user_id = wc_create_new_customer( 'unlock2@example.com', 'unlockuser2', 'pw' );
-		$this->force_lockout( $user_id );
+	public function test_build_verification_url_carries_valid_key(): void {
+		$user_id = wc_create_new_customer( 'url@example.com', 'urluser', 'pw' );

-		$this->sut->clear_verification( $user_id );
+		$url = $this->sut->build_verification_url( $user_id );
+		parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $args );

-		$this->assertFalse( $this->sut->is_locked_out( $user_id ), 'Clearing verification must clear the lockout' );
+		$this->assertArrayHasKey( 'wc_verify_email_key', $args, 'URL must carry the key' );
+		$this->assertArrayHasKey( 'wc_verify_email_user', $args, 'URL must carry the user ID' );
+		$this->assertSame( (string) $user_id, (string) $args['wc_verify_email_user'], 'URL must carry the correct user ID' );
+		$this->assertTrue( $this->sut->check_verification_key( $user_id, (string) $args['wc_verify_email_key'] ), 'The URL key should validate' );
 	}

 	/**
-	 * @testdox An expired code is reported as expired and does not count as a failed guess.
+	 * @testdox An expired key fails the check and is no longer pending.
 	 */
-	public function test_expired_code_not_counted_as_failure(): void {
+	public function test_expired_key_fails(): void {
 		$user_id = wc_create_new_customer( 'exp@example.com', 'expuser', 'pw' );

-		$code = $this->sut->create_code( $user_id );
+		$key = $this->sut->create_verification_key( $user_id );

-		// Age the stored code past the 10-minute TTL, keeping its hash and attempt count intact.
-		$parts    = explode( ':', (string) Users::get_site_user_meta( $user_id, self::KEY_META ), 4 );
-		$parts[0] = (string) ( time() - 11 * MINUTE_IN_SECONDS );
+		// Age the stored token past the 24-hour TTL, keeping its hashes intact.
+		$parts    = explode( ':', (string) Users::get_site_user_meta( $user_id, self::KEY_META ), 3 );
+		$parts[0] = (string) ( time() - DAY_IN_SECONDS - HOUR_IN_SECONDS );
 		Users::update_site_user_meta( $user_id, self::KEY_META, implode( ':', $parts ) );

-		$this->assertSame( EmailVerificationService::RESULT_EXPIRED, $this->sut->verify_code( $user_id, $code ) );
-		$this->assertFalse( $this->sut->is_locked_out( $user_id ), 'An expiry must not move the user towards lockout' );
-		$this->assertFalse( $this->sut->has_pending_code( $user_id ), 'An expired code is no longer pending' );
+		$this->assertFalse( $this->sut->check_verification_key( $user_id, $key ), 'An expired key must fail the check' );
+		$this->assertFalse( $this->sut->has_pending_key( $user_id ), 'An expired key is no longer pending' );
 	}

 	/**
-	 * @testdox A code is void after the account email changes, so it can't verify a different address.
+	 * @testdox A key is void after the account email changes, so it can't verify a different address.
 	 */
-	public function test_code_void_after_email_change(): void {
-		$user_id = wc_create_new_customer( 'issued-for@example.com', 'codechange', 'pw' );
+	public function test_key_void_after_email_change(): void {
+		$user_id = wc_create_new_customer( 'issued-for@example.com', 'keychange', 'pw' );

-		$code = $this->sut->create_code( $user_id );
+		$key = $this->sut->create_verification_key( $user_id );

 		wp_update_user(
 			array(
@@ -218,10 +147,9 @@ class EmailVerificationServiceTest extends WC_Unit_Test_Case {
 		);
 		clean_user_cache( $user_id );

-		$this->assertSame(
-			EmailVerificationService::RESULT_NONE,
-			$this->sut->verify_code( $user_id, $code ),
-			'A code minted for the old email must not verify the new email'
+		$this->assertFalse(
+			$this->sut->check_verification_key( $user_id, $key ),
+			'A key minted for the old email must not verify the new email'
 		);
 		$this->assertFalse( $this->sut->is_verified( $user_id ) );
 	}
@@ -278,31 +206,4 @@ class EmailVerificationServiceTest extends WC_Unit_Test_Case {

 		$this->assertTrue( $this->sut->is_verified( $user_id ), 'Non-email profile changes must not invalidate verification' );
 	}
-
-	/**
-	 * Return a six-digit code guaranteed to differ from the given one.
-	 *
-	 * @param string $code The code to avoid.
-	 * @return string
-	 */
-	private function wrong_code( string $code ): string {
-		return '000000' === $code ? '111111' : '000000';
-	}
-
-	/**
-	 * Drive the service into a locked-out state for the given user.
-	 *
-	 * @param int $user_id User ID.
-	 */
-	private function force_lockout( int $user_id ): void {
-		$current = null;
-		$guard   = 0;
-		while ( ! $this->sut->is_locked_out( $user_id ) && $guard < 15 ) {
-			if ( ! $this->sut->has_pending_code( $user_id ) ) {
-				$current = $this->sut->create_code( $user_id );
-			}
-			$this->sut->verify_code( $user_id, $this->wrong_code( (string) $current ) );
-			++$guard;
-		}
-	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmailTest.php b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmailTest.php
index 0a0c41125a7..bc327c2a121 100644
--- a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmailTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/Emails/CustomerVerifyEmailTest.php
@@ -42,16 +42,17 @@ class CustomerVerifyEmailTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox trigger() sends an email to the customer containing the verification code.
+	 * @testdox trigger() sends an email to the customer containing the verification link.
 	 */
-	public function test_trigger_sends_email_with_code(): void {
+	public function test_trigger_sends_email_with_link(): void {
 		$user_id = wc_create_new_customer( 'verify@example.com', 'verifytestuser', 'password' );
 		$this->assertIsInt( $user_id );

 		$mailer = tests_retrieve_phpmailer_instance();
 		$before = count( $mailer->mock_sent );

-		$this->sut->trigger( $user_id, '654321' );
+		$verify_url = 'https://example.com/?wc_verify_email_key=abc123&wc_verify_email_user=' . $user_id;
+		$this->sut->trigger( $user_id, $verify_url );

 		$after = count( $mailer->mock_sent );

@@ -59,11 +60,11 @@ class CustomerVerifyEmailTest extends WC_Unit_Test_Case {

 		$sent = $mailer->mock_sent[ $before ];
 		$this->assertSame( 'verify@example.com', $sent['to'][0][0], 'Email must be addressed to the customer.' );
-		$this->assertStringContainsString( '654321', $sent['body'], 'Email body must contain the verification code.' );
+		$this->assertStringContainsString( 'wc_verify_email_key=abc123', $sent['body'], 'Email body must contain the verification link.' );
 	}

 	/**
-	 * @testdox trigger() is a no-op when user_id or verify_code is missing.
+	 * @testdox trigger() is a no-op when user_id or verify_url is missing.
 	 */
 	public function test_trigger_noop_without_args(): void {
 		$mailer = tests_retrieve_phpmailer_instance();
diff --git a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/MyAccountPromptTest.php b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/MyAccountPromptTest.php
index b82886fb5a7..337bf3faae7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/MyAccountPromptTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/MyAccountPromptTest.php
@@ -41,7 +41,6 @@ class MyAccountPromptTest extends WC_Unit_Test_Case {
 	public function tearDown(): void {
 		wp_set_current_user( 0 );
 		wc_clear_notices();
-		wp_deregister_script( 'wc-customer-email-verification' );
 		parent::tearDown();
 	}

@@ -56,35 +55,6 @@ class MyAccountPromptTest extends WC_Unit_Test_Case {
 		return (string) ob_get_clean();
 	}

-	/**
-	 * Render the verify-account endpoint content and return its HTML.
-	 *
-	 * @return string
-	 */
-	private function render_endpoint(): string {
-		ob_start();
-		$this->sut->render_endpoint_content();
-		return (string) ob_get_clean();
-	}
-
-	/**
-	 * Drive the service into a locked-out state for the given user.
-	 *
-	 * @param int $user_id User ID.
-	 */
-	private function force_lockout( int $user_id ): void {
-		$current = null;
-		$guard   = 0;
-		while ( ! $this->service->is_locked_out( $user_id ) && $guard < 15 ) {
-			if ( ! $this->service->has_pending_code( $user_id ) ) {
-				$current = $this->service->create_code( $user_id );
-			}
-			$wrong = '000000' === $current ? '111111' : '000000';
-			$this->service->verify_code( $user_id, $wrong );
-			++$guard;
-		}
-	}
-
 	/**
 	 * Invoke handle_send_request(), trapping the wp_safe_redirect() + exit it ends with.
 	 *
@@ -93,19 +63,22 @@ class MyAccountPromptTest extends WC_Unit_Test_Case {
 	 * PHPUnit run, silently skipping every later test. Throwing from the wp_redirect
 	 * filter aborts control flow before exit so the test survives to assert.
 	 */
-	private function dispatch_send_request(): void {
-		$abort = static function ( $location ): void {
-			throw new \RuntimeException( esc_html( (string) $location ) );
+	private function dispatch_send_request(): string {
+		$location = '';
+		$abort    = static function ( $loc ): void {
+			throw new \RuntimeException( esc_html( (string) $loc ) );
 		};
 		add_filter( 'wp_redirect', $abort );
 		try {
 			$this->sut->handle_send_request();
 		} catch ( \RuntimeException $e ) {
 			// Expected: handle_send_request() redirects and exits.
-			unset( $e );
+			$location = $e->getMessage();
 		} finally {
 			remove_filter( 'wp_redirect', $abort );
 		}
+
+		return $location;
 	}

 	// -------------------------------------------------------------------------
@@ -169,83 +142,31 @@ class MyAccountPromptTest extends WC_Unit_Test_Case {
 	// -------------------------------------------------------------------------

 	/**
-	 * @testdox The prompt shows a send-code call to action when no code is pending.
+	 * @testdox The prompt shows a send-link call to action when no link is pending.
 	 */
-	public function test_prompt_renders_send_cta_when_no_code(): void {
+	public function test_prompt_renders_send_cta_when_no_key(): void {
 		$user_id = wc_create_new_customer( 'cta-prompt@example.com', 'ctapromptuser', 'pw' );
 		wp_set_current_user( $user_id );

 		$html = $this->render_prompt();

-		$this->assertStringContainsString( 'wc_send_verification', $html, 'A prompt with no pending code should carry the send-code action.' );
-		$this->assertStringNotContainsString( 'name="wc_verify_email_code"', $html, 'No entry form should show before a code is sent.' );
+		$this->assertStringContainsString( 'wc_send_verification', $html, 'A prompt with no pending link should carry the send-link action.' );
 	}

 	/**
-	 * @testdox The orders prompt links to the /orders/verify/ sub-page (not the form) when a code is pending.
+	 * @testdox The prompt points the customer to their inbox (no resend CTA) while a link was just sent.
 	 */
-	public function test_orders_prompt_links_to_endpoint_when_pending(): void {
+	public function test_prompt_points_to_inbox_when_recently_sent(): void {
 		$user_id = wc_create_new_customer( 'inbox-prompt@example.com', 'inboxpromptuser', 'pw' );
 		wp_set_current_user( $user_id );

-		// A code was just sent.
-		$this->service->create_code( $user_id );
-
-		$html         = $this->render_prompt();
-		$expected_url = wc_get_endpoint_url( 'orders', 'verify', wc_get_page_permalink( 'myaccount' ) );
-
-		$this->assertStringContainsString( esc_url( $expected_url ), $html, 'The pending notice should point to the /orders/verify/ sub-page.' );
-		$this->assertStringNotContainsString( 'name="wc_verify_email_code"', $html, 'The form must not render on the orders panel.' );
-		$this->assertFalse( wp_script_is( 'wc-customer-email-verification', 'enqueued' ), 'The orders notice must not enqueue the form script.' );
-	}
-
-	// -------------------------------------------------------------------------
-	// render_endpoint_content()
-	// -------------------------------------------------------------------------
-
-	/**
-	 * @testdox The verify-account endpoint renders the code-entry form and enqueues its script when a code is pending.
-	 */
-	public function test_endpoint_renders_code_form_when_pending(): void {
-		$user_id = wc_create_new_customer( 'endpoint-form@example.com', 'endpointformuser', 'pw' );
-		wp_set_current_user( $user_id );
-
-		$this->service->create_code( $user_id );
-
-		$html = $this->render_endpoint();
-
-		$this->assertStringContainsString( 'name="wc_verify_email_code"', $html, 'A pending code should surface the entry form on the endpoint.' );
-		$this->assertStringContainsString( 'type="hidden" name="wc_verify_email_submit"', $html, 'The submit marker must be a hidden field so the form stays routable regardless of the submit button state.' );
-		$this->assertTrue( wp_script_is( 'wc-customer-email-verification', 'enqueued' ), 'Rendering the endpoint form should enqueue its enhancement script.' );
-	}
-
-	/**
-	 * @testdox The verify-account endpoint shows the contact-the-owner message once the user is locked out.
-	 */
-	public function test_endpoint_renders_locked_message_when_locked_out(): void {
-		$user_id = wc_create_new_customer( 'endpoint-locked@example.com', 'endpointlocked', 'pw' );
-		wp_set_current_user( $user_id );
-		$this->force_lockout( $user_id );
-
-		$html = $this->render_endpoint();
-
-		$this->assertStringContainsString( 'store owner', $html, 'A locked-out user should be told to contact the store owner.' );
-		$this->assertStringNotContainsString( 'name="wc_verify_email_code"', $html, 'A locked-out user must not see the entry form.' );
-	}
-
-	/**
-	 * @testdox The prompt shows a contact-the-owner message once the user is locked out.
-	 */
-	public function test_prompt_renders_locked_message_when_locked_out(): void {
-		$user_id = wc_create_new_customer( 'locked-prompt@example.com', 'lockedpromptuser', 'pw' );
-		wp_set_current_user( $user_id );
-
-		$this->force_lockout( $user_id );
+		// A link was just sent (seconds_since_last_key < rate-limit window).
+		$this->service->create_verification_key( $user_id );

 		$html = $this->render_prompt();

-		$this->assertStringContainsString( 'store owner', $html, 'A locked-out user should be told to contact the store owner.' );
-		$this->assertStringNotContainsString( 'name="wc_verify_email_code"', $html, 'A locked-out user must not see the entry form.' );
+		$this->assertStringContainsString( 'check your inbox', $html, 'A just-sent prompt should point the customer to their inbox.' );
+		$this->assertStringNotContainsString( 'wc_send_verification', $html, 'A just-sent prompt must not offer an immediate resend.' );
 	}

 	// -------------------------------------------------------------------------
@@ -311,42 +232,39 @@ class MyAccountPromptTest extends WC_Unit_Test_Case {
 		};
 		add_action( 'woocommerce_customer_verify_email_notification', $listener );

-		// First send (no existing code).
+		// First send (no existing key).
 		$_GET['_wpnonce'] = wp_create_nonce( 'woocommerce-send-verification-email' );
 		$this->dispatch_send_request();

-		// Second send — code was just created (seconds_since_last_key < 60).
+		// Second send — key was just created (seconds_since_last_key < 60).
 		$_GET['_wpnonce'] = wp_create_nonce( 'woocommerce-send-verification-email' );
-		$this->dispatch_send_request();
+		$throttled        = $this->dispatch_send_request();

 		remove_action( 'woocommerce_customer_verify_email_notification', $listener );
 		unset( $_GET['_wpnonce'] );

 		$this->assertSame( 1, $notification_count, 'Notification should fire exactly once despite two send attempts within the rate-limit window' );
-		$this->assertCount( 1, wc_get_notices( 'notice' ), 'A rate-limited resend must surface an informational notice instead of failing silently.' );
+		$this->assertStringContainsString( 'wc_verify_notice=throttled', $throttled, 'A rate-limited resend must surface the throttled result notice instead of failing silently.' );
 	}

 	/**
-	 * @testdox handle_send_request does not mint a new code for a locked-out user.
+	 * @testdox print_result_notice prints the matching notice for a known result code and nothing for an unknown one.
 	 */
-	public function test_handle_send_request_does_not_mint_when_locked_out(): void {
-		$user_id = wc_create_new_customer( 'locked-send@example.com', 'lockedsenduser', 'pw' );
-		$this->force_lockout( $user_id );
-		wp_set_current_user( $user_id );
-
-		$notification_fired = false;
-		$listener           = static function () use ( &$notification_fired ) {
-			$notification_fired = true;
-		};
-		add_action( 'woocommerce_customer_verify_email_notification', $listener );
+	public function test_print_result_notice_renders_only_known_codes(): void {
+		$_GET['wc_verify_notice'] = 'sent';
+		ob_start();
+		$this->sut->print_result_notice();
+		$sent_html = (string) ob_get_clean();

-		$_GET['_wpnonce'] = wp_create_nonce( 'woocommerce-send-verification-email' );
-		$this->dispatch_send_request();
+		$_GET['wc_verify_notice'] = 'not-a-real-code';
+		ob_start();
+		$this->sut->print_result_notice();
+		$unknown_html = (string) ob_get_clean();

-		remove_action( 'woocommerce_customer_verify_email_notification', $listener );
-		unset( $_GET['_wpnonce'] );
+		unset( $_GET['wc_verify_notice'] );

-		$this->assertFalse( $notification_fired, 'A locked-out user must not be able to mint fresh codes' );
+		$this->assertStringContainsString( 'check your inbox', $sent_html, 'A known result code should print its notice.' );
+		$this->assertSame( '', $unknown_html, 'An unknown result code should print nothing (no stray notice).' );
 	}

 	// -------------------------------------------------------------------------
@@ -354,26 +272,26 @@ class MyAccountPromptTest extends WC_Unit_Test_Case {
 	// -------------------------------------------------------------------------

 	/**
-	 * @testdox seconds_since_last_key returns null when no code has been issued.
+	 * @testdox seconds_since_last_key returns null when no key has been issued.
 	 */
 	public function test_seconds_since_last_key_returns_null_with_no_key(): void {
 		$user_id = wc_create_new_customer( 'nokey@example.com', 'nokeyuser', 'pw' );

-		$this->assertNull( $this->service->seconds_since_last_key( $user_id ), 'Should return null when no code has been issued' );
+		$this->assertNull( $this->service->seconds_since_last_key( $user_id ), 'Should return null when no key has been issued' );
 	}

 	/**
-	 * @testdox seconds_since_last_key returns a small non-negative integer immediately after code creation.
+	 * @testdox seconds_since_last_key returns a small non-negative integer immediately after key creation.
 	 */
 	public function test_seconds_since_last_key_returns_small_value_after_key_creation(): void {
 		$user_id = wc_create_new_customer( 'freshkey@example.com', 'freshkeyuser', 'pw' );
-		$this->service->create_code( $user_id );
+		$this->service->create_verification_key( $user_id );

 		$elapsed = $this->service->seconds_since_last_key( $user_id );

-		$this->assertNotNull( $elapsed, 'Should return an integer after code creation' );
+		$this->assertNotNull( $elapsed, 'Should return an integer after key creation' );
 		$this->assertGreaterThanOrEqual( 0, $elapsed, 'Elapsed time should never be negative' );
 		// Generous upper bound: proves a real, recent elapsed value without being flaky on a slow runner.
-		$this->assertLessThan( 60, $elapsed, 'Elapsed time should be well within the rate-limit window after code creation' );
+		$this->assertLessThan( 60, $elapsed, 'Elapsed time should be well within the rate-limit window after key creation' );
 	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/VerificationControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/VerificationControllerTest.php
index 69f67e53345..3c6de0cf5bf 100644
--- a/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/VerificationControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/CustomerEmailVerification/VerificationControllerTest.php
@@ -8,7 +8,7 @@ use Automattic\WooCommerce\Internal\CustomerEmailVerification\VerificationContro
 use WC_Unit_Test_Case;

 /**
- * Tests for the VerificationController class.
+ * Tests for the VerificationController class (login-gated magic-link flow).
  */
 class VerificationControllerTest extends WC_Unit_Test_Case {

@@ -39,182 +39,142 @@ class VerificationControllerTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox send_verification_email() emits a six-digit code, and submitting it verifies and links orders.
+	 * @testdox send_verification_email() emits a link carrying the key, and confirming it as the link owner verifies and links orders.
 	 */
-	public function test_send_then_submit_code_links_orders(): void {
+	public function test_send_then_confirm_as_owner_links_orders(): void {
 		$email   = 'roundtrip@example.com';
 		$user_id = wc_create_new_customer( $email, 'roundtripuser', 'pw' );
 		$order   = $this->create_guest_order( $email );

-		$code = $this->capture_sent_code( $user_id );
-		$this->assertMatchesRegularExpression( '/^\d{6}$/', $code, 'The emailed value should be a six-digit code' );
+		$verify_url = $this->capture_sent_url( $user_id );
+		$this->assertStringContainsString( 'wc_verify_email_key=', $verify_url, 'The emailed value should be a verify URL carrying a key' );
+		$key = $this->key_from_url( $verify_url );

 		wp_set_current_user( $user_id );
-		$redirect        = $this->submit_code( $code, wp_create_nonce( 'woocommerce-verify-email' ) );
-		$success_notices = wc_get_notices( 'success' );
-		wc_clear_notices();
+		$redirect = $this->open_verify_link( $user_id, $key );
 		wp_set_current_user( 0 );

-		$this->assertTrue( $this->service->is_verified( $user_id ), 'A correct code should verify the address' );
+		$this->assertTrue( $this->service->is_verified( $user_id ), 'Opening the link as the owner should verify the address' );
 		$this->assertSame( $user_id, wc_get_order( $order->get_id() )->get_customer_id(), 'Guest order should link to the verified customer' );
-		$this->assertNotEmpty( $success_notices, 'A confirmation notice should be shown' );
-		$this->assertStringContainsString( 'orders', $redirect, 'Should redirect to the Orders endpoint' );
+		$this->assertStringContainsString( 'wc_verify_notice=confirmed', $redirect, 'Should redirect to the Orders endpoint carrying the confirmed result notice' );
 	}

 	/**
-	 * @testdox A wrong code shows an error, does not verify, and leaves the code pending.
+	 * @testdox LOCKDOWN: the same valid link verifies ONLY when opened as the target — logged out (prefetch) and cross-account never consume the key.
+	 *
+	 * This is the core security property: verification is gated on being authenticated as the link's
+	 * target user, so neither a prefetch (always logged out) nor any other account can spend the key.
 	 */
-	public function test_wrong_code_errors_and_keeps_pending(): void {
-		$user_id = wc_create_new_customer( 'wrong@example.com', 'wronguser', 'pw' );
-		$code    = $this->service->create_code( $user_id );
-		$wrong   = '000000' === $code ? '111111' : '000000';
+	public function test_verify_link_requires_authentication_as_target(): void {
+		$owner_id = wc_create_new_customer( 'lockdown-owner@example.com', 'lockdownowner', 'pw' );
+		$other_id = wc_create_new_customer( 'lockdown-other@example.com', 'lockdownother', 'pw' );
+		$key      = $this->service->create_verification_key( $owner_id );

-		wp_set_current_user( $user_id );
-		$this->submit_code( $wrong, wp_create_nonce( 'woocommerce-verify-email' ) );
-		$error_notices = wc_get_notices( 'error' );
-		wc_clear_notices();
+		// Logged out (the prefetch case): inert — no verification, key untouched.
 		wp_set_current_user( 0 );
-
-		$this->assertFalse( $this->service->is_verified( $user_id ), 'A wrong code must not verify' );
-		$this->assertTrue( $this->service->has_pending_code( $user_id ), 'A wrong code (attempts remaining) must stay pending' );
-		$this->assertNotEmpty( $error_notices, 'A wrong code should produce an error notice' );
-	}
-
-	/**
-	 * @testdox A submission with an invalid nonce does not verify or consume the code.
-	 */
-	public function test_submission_requires_valid_nonce(): void {
-		$user_id = wc_create_new_customer( 'bad-nonce@example.com', 'badnonce', 'pw' );
-		$code    = $this->service->create_code( $user_id );
-
-		wp_set_current_user( $user_id );
-		$this->submit_code( $code, 'not-a-valid-nonce' );
-		$error_notices = wc_get_notices( 'error' );
+		$this->open_verify_link( $owner_id, $key );
 		wc_clear_notices();
+		$this->assertFalse( $this->service->is_verified( $owner_id ), 'A logged-out open must not verify' );
+		$this->assertTrue( $this->service->has_pending_key( $owner_id ), 'A logged-out open must not consume the key' );
+
+		// Logged in as a different account: refused, key still intact.
+		wp_set_current_user( $other_id );
+		$this->open_verify_link( $owner_id, $key );
+		$this->assertFalse( $this->service->is_verified( $owner_id ), 'A cross-account open must not verify the owner' );
+		$this->assertTrue( $this->service->has_pending_key( $owner_id ), 'A cross-account open must not consume the key' );
+
+		// Logged in as the target: the key (untouched by the prior attempts) now verifies and is spent.
+		wp_set_current_user( $owner_id );
+		$this->open_verify_link( $owner_id, $key );
 		wp_set_current_user( 0 );
-
-		$this->assertFalse( $this->service->is_verified( $user_id ), 'An invalid nonce must not verify the address' );
-		$this->assertTrue( $this->service->has_pending_code( $user_id ), 'An invalid nonce must not consume the code' );
-		$this->assertNotEmpty( $error_notices, 'An invalid request should produce an error notice' );
+		$this->assertTrue( $this->service->is_verified( $owner_id ), 'Opening as the target should verify' );
+		$this->assertFalse( $this->service->has_pending_key( $owner_id ), 'Verifying consumes the key' );
 	}

 	/**
-	 * @testdox A code submission with no logged-in user does not verify.
+	 * @testdox LOGIN GATE: opening the verify-link while logged out renders the My Account login with a notice, and never verifies or consumes the key.
+	 *
+	 * The verify params stay in the page URL, so signing in on that login form returns the visitor here
+	 * to complete verification.
 	 */
-	public function test_submission_without_session_does_not_verify(): void {
-		$user_id = wc_create_new_customer( 'nosession@example.com', 'nosession', 'pw' );
-		$code    = $this->service->create_code( $user_id );
+	public function test_logged_out_open_renders_login_with_notice(): void {
+		$user_id = wc_create_new_customer( 'openlink@example.com', 'openlinkuser', 'pw' );
+		$key     = $this->service->create_verification_key( $user_id );

 		wp_set_current_user( 0 );
-		$redirect = $this->submit_code( $code, wp_create_nonce( 'woocommerce-verify-email' ) );
+		$redirect       = $this->open_verify_link( $user_id, $key );
+		$notice_notices = wc_get_notices( 'notice' );
 		wc_clear_notices();

-		$this->assertFalse( $this->service->is_verified( $user_id ), 'A logged-out submission must not verify anyone' );
-		$this->assertStringContainsString( 'orders', $redirect, 'Should redirect to the Orders endpoint' );
+		$this->assertSame( '', $redirect, 'A logged-out open must render the login in place, not redirect or verify' );
+		$this->assertFalse( $this->service->is_verified( $user_id ), 'Opening the link logged out must not verify anyone' );
+		$this->assertTrue( $this->service->has_pending_key( $user_id ), 'Opening the link logged out must not consume the key' );
+		$this->assertNotEmpty( $notice_notices, 'A logged-out open should explain that login is required' );
 	}

 	/**
-	 * @testdox Submitting twice after success shows success again, not a stale error.
+	 * @testdox LOGIN GATE: opening the link while logged in as a different account verifies no one and shows the mismatch notice.
 	 */
-	public function test_double_submission_does_not_error(): void {
-		$user_id = wc_create_new_customer( 'double@example.com', 'doubleuser', 'pw' );
-		$code    = $this->service->create_code( $user_id );
+	public function test_cross_account_open_does_not_verify(): void {
+		$owner_id = wc_create_new_customer( 'owner@example.com', 'owneruser', 'pw' );
+		$other_id = wc_create_new_customer( 'other@example.com', 'otheruser', 'pw' );
+		$key      = $this->service->create_verification_key( $owner_id );

-		wp_set_current_user( $user_id );
-		$nonce = wp_create_nonce( 'woocommerce-verify-email' );
-		$this->submit_code( $code, $nonce );
-		$this->submit_code( $code, $nonce );
-		$error_notices   = wc_get_notices( 'error' );
-		$success_notices = wc_get_notices( 'success' );
-		wc_clear_notices();
+		wp_set_current_user( $other_id );
+		$redirect = $this->open_verify_link( $owner_id, $key );
 		wp_set_current_user( 0 );

-		$this->assertTrue( $this->service->is_verified( $user_id ), 'The first submission should verify the address' );
-		$this->assertEmpty( $error_notices, 'A repeat submission once verified must not error' );
-		$this->assertCount( 2, $success_notices, 'Each submission once verified should report success' );
+		$this->assertFalse( $this->service->is_verified( $owner_id ), 'The link owner must not be verified by another account' );
+		$this->assertFalse( $this->service->is_verified( $other_id ), 'The logged-in different account must not be verified' );
+		$this->assertTrue( $this->service->has_pending_key( $owner_id ), 'A cross-account open must not consume the key' );
+		$this->assertStringContainsString( 'wc_verify_notice=mismatch', $redirect, 'A cross-account open should surface the mismatch result notice' );
 	}

 	/**
-	 * @testdox The orders endpoint renders the verification form for the "verify" value and nothing otherwise.
+	 * @testdox Opening the link as the owner with a wrong/expired key errors and does not verify.
 	 */
-	public function test_orders_endpoint_renders_form_on_verify_value(): void {
-		$user_id = wc_create_new_customer( 'orders-verify@example.com', 'ordersverify', 'pw' );
-		wp_set_current_user( $user_id );
-		$this->service->create_code( $user_id );
-
-		// A page-number value is a no-op (the orders list renders as usual).
-		ob_start();
-		$this->ctrl->maybe_render_on_orders_endpoint( '2' );
-		$this->assertSame( '', ob_get_clean(), 'A normal page value must not render the verification form.' );
-
-		// The reserved "verify" value renders the form.
-		ob_start();
-		$this->ctrl->maybe_render_on_orders_endpoint( 'verify' );
-		$html = (string) ob_get_clean();
+	public function test_invalid_key_errors_and_does_not_verify(): void {
+		$user_id = wc_create_new_customer( 'wrongkey@example.com', 'wrongkeyuser', 'pw' );
+		$this->service->create_verification_key( $user_id );

-		// Restore the core orders-list callback the verify branch removed.
-		add_action( 'woocommerce_account_orders_endpoint', 'woocommerce_account_orders' );
+		wp_set_current_user( $user_id );
+		$redirect = $this->open_verify_link( $user_id, 'totally-wrong-key' );
 		wp_set_current_user( 0 );

-		$this->assertStringContainsString( 'name="wc_verify_email_code"', $html, 'The /orders/verify/ sub-page must render the entry form.' );
+		$this->assertFalse( $this->service->is_verified( $user_id ), 'A wrong key must not verify the address' );
+		$this->assertStringContainsString( 'wc_verify_notice=expired', $redirect, 'A wrong key should surface the expired result notice' );
 	}

 	/**
-	 * @testdox The verify sub-page title is normalised to "Orders" instead of the default "Orders (Page 0)".
+	 * @testdox Re-opening the link after the address is already verified lands on Orders without repeating the success notice.
 	 */
-	public function test_orders_title_on_verify_subpage(): void {
-		// The sub-page reuses the orders pagination slot, so WooCommerce titles it "Orders (Page 0)";
-		// the filter forces a clean "Orders".
-		set_query_var( 'orders', 'verify' );
-		$this->assertSame( 'Orders', $this->ctrl->maybe_filter_orders_title( 'Orders (Page 0)' ) );
-
-		set_query_var( 'orders', '2' );
-		$this->assertSame( 'Orders (Page 2)', $this->ctrl->maybe_filter_orders_title( 'Orders (Page 2)' ), 'A normal orders page keeps its title.' );
+	public function test_reopening_link_after_verified_shows_no_success_notice(): void {
+		$user_id = wc_create_new_customer( 'reopen@example.com', 'reopenuser', 'pw' );
+		$key     = $this->service->create_verification_key( $user_id );

-		set_query_var( 'orders', '' );
-	}
-
-	/**
-	 * @testdox A verified customer who lands on the /orders/verify/ sub-page is redirected to orders.
-	 */
-	public function test_verify_subpage_redirects_verified_user_to_orders(): void {
-		$user_id = wc_create_new_customer( 'verified-endpoint@example.com', 'verifiedendpoint', 'pw' );
-		$this->service->mark_verified( $user_id );
 		wp_set_current_user( $user_id );
+		$first  = $this->open_verify_link( $user_id, $key );
+		$second = $this->open_verify_link( $user_id, $key );
+		wp_set_current_user( 0 );

-		// Simulate a request on /orders/verify/ (get_query_var reads from $wp_query).
-		set_query_var( 'orders', 'verify' );
-
-		$redirect = '';
-		$abort    = static function ( $location ) {
-			throw new \RuntimeException( esc_html( (string) $location ) );
-		};
-		add_filter( 'wp_redirect', $abort );
-		try {
-			$this->ctrl->maybe_process_request();
-		} catch ( \RuntimeException $e ) {
-			$redirect = $e->getMessage();
-		} finally {
-			remove_filter( 'wp_redirect', $abort );
-			set_query_var( 'orders', '' );
-			wp_set_current_user( 0 );
-		}
-
-		$this->assertStringContainsString( 'orders', $redirect, 'A verified user with nothing to verify should be bounced to orders.' );
+		$this->assertTrue( $this->service->is_verified( $user_id ), 'The first open should verify the address' );
+		$this->assertStringContainsString( 'wc_verify_notice=confirmed', $first, 'The first open should report success' );
+		$this->assertStringContainsString( 'orders', $second, 'A re-open once verified should still land on Orders' );
+		$this->assertStringNotContainsString( 'wc_verify_notice=', $second, 'A re-open once verified must not repeat the success notice' );
 	}

 	/**
-	 * Capture the code emitted by send_verification_email().
+	 * Capture the verify URL emitted by send_verification_email().
 	 *
 	 * @param int $user_id User to send to.
-	 * @return string The captured code.
+	 * @return string The captured URL.
 	 */
-	private function capture_sent_code( int $user_id ): string {
+	private function capture_sent_url( int $user_id ): string {
 		$captured = '';
-		$listener = static function ( $uid, $code ) use ( &$captured ) {
+		$listener = static function ( $uid, $url ) use ( &$captured ) {
 			// The $uid arg is unused but required by the two-argument hook signature.
 			unset( $uid );
-			$captured = $code;
+			$captured = $url;
 		};
 		add_action( 'woocommerce_customer_verify_email_notification', $listener, 10, 2 );
 		$this->ctrl->send_verification_email( $user_id );
@@ -223,6 +183,19 @@ class VerificationControllerTest extends WC_Unit_Test_Case {
 		return (string) $captured;
 	}

+	/**
+	 * Extract the plaintext key from a verify URL.
+	 *
+	 * @param string $url Verify URL.
+	 * @return string
+	 */
+	private function key_from_url( string $url ): string {
+		$query = (string) wp_parse_url( $url, PHP_URL_QUERY );
+		parse_str( $query, $args );
+
+		return isset( $args['wc_verify_email_key'] ) ? (string) $args['wc_verify_email_key'] : '';
+	}
+
 	/**
 	 * Create a guest order with the given billing email (linkable to a matching customer).
 	 *
@@ -239,20 +212,20 @@ class VerificationControllerTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * Drive a code-form POST through the controller, returning the captured redirect target.
+	 * Open a verify-link (GET) through the controller, returning the captured redirect target.
 	 *
-	 * handle_code_submission() ends in wp_safe_redirect()/exit; a filter throws the redirect target
-	 * so the exit is never reached and the test can assert on the outcome.
+	 * handle_verify_link() ends in wp_safe_redirect()/exit on every path except the logged-out one (which
+	 * renders the login in place); a filter throws the redirect target so the exit is never reached. The
+	 * returned value is the redirect location, or '' when the handler rendered without redirecting.
 	 *
-	 * @param string $code  Code to submit.
-	 * @param string $nonce Nonce value to submit.
-	 * @return string The redirect location the handler attempted.
+	 * @param int    $user_id User ID in the link.
+	 * @param string $key     Key in the link.
+	 * @return string The redirect location the handler attempted, or '' if it rendered in place.
 	 */
-	private function submit_code( string $code, string $nonce ): string {
-		$_SERVER['REQUEST_METHOD']       = 'POST';
-		$_POST['wc_verify_email_submit'] = '1';
-		$_POST['_wpnonce']               = $nonce;
-		$_POST['wc_verify_email_code']   = $code;
+	private function open_verify_link( int $user_id, string $key ): string {
+		$_SERVER['REQUEST_METHOD']    = 'GET';
+		$_GET['wc_verify_email_user'] = (string) $user_id;
+		$_GET['wc_verify_email_key']  = $key;

 		$redirect = '';
 		$abort    = static function ( $location ) {
@@ -265,12 +238,7 @@ class VerificationControllerTest extends WC_Unit_Test_Case {
 			$redirect = $e->getMessage();
 		} finally {
 			remove_filter( 'wp_redirect', $abort );
-			$_SERVER['REQUEST_METHOD'] = 'GET';
-			unset(
-				$_POST['wc_verify_email_submit'],
-				$_POST['_wpnonce'],
-				$_POST['wc_verify_email_code']
-			);
+			unset( $_GET['wc_verify_email_user'], $_GET['wc_verify_email_key'] );
 		}

 		return $redirect;