diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f8a41c4643e4..6a12e4121b19 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4574,6 +4574,9 @@ importers:
'@automattic/jetpack-wp-build-polyfills':
specifier: workspace:*
version: link:../wp-build-polyfills
+ '@automattic/request-external-access':
+ specifier: 1.0.1
+ version: 1.0.1
'@wordpress/api-fetch':
specifier: 7.46.0
version: 7.46.0
diff --git a/projects/packages/seo/_inc/data/google-verify-types.ts b/projects/packages/seo/_inc/data/google-verify-types.ts
new file mode 100644
index 000000000000..db074093ee1c
--- /dev/null
+++ b/projects/packages/seo/_inc/data/google-verify-types.ts
@@ -0,0 +1,20 @@
+// Google site-verification state.
+//
+// The bootstrap (keyring connect URL + whether the current user is connected) is
+// injected onto `window.JetpackScriptData.seo.google_verify` by
+// `Initializer::get_google_verify_data()`. The live verified status is fetched
+// client-side from `/jetpack/v4/verify-site/google` (a wpcom round-trip).
+
+export interface GoogleVerifyBootstrap {
+ /** WordPress.com keyring OAuth URL that opens the auto-verify popup. */
+ connect_url: string;
+ /** Whether the current user is connected to WordPress.com (keyring available). */
+ is_connected: boolean;
+}
+
+/** Shape returned by GET/POST `/jetpack/v4/verify-site/google`. */
+export interface GoogleVerifyStatus {
+ verified: boolean;
+ is_owner: boolean;
+ google_search_console_url: string;
+}
diff --git a/projects/packages/seo/_inc/data/use-google-verify.ts b/projects/packages/seo/_inc/data/use-google-verify.ts
new file mode 100644
index 000000000000..a03bfb732afd
--- /dev/null
+++ b/projects/packages/seo/_inc/data/use-google-verify.ts
@@ -0,0 +1,110 @@
+import { getScriptData } from '@automattic/jetpack-script-data';
+import requestExternalAccess from '@automattic/request-external-access';
+import apiFetch from '@wordpress/api-fetch';
+import { useCallback, useEffect, useMemo, useState } from '@wordpress/element';
+import type { GoogleVerifyBootstrap, GoogleVerifyStatus } from './google-verify-types';
+
+const ENDPOINT = '/jetpack/v4/verify-site/google';
+
+type SeoScriptData = {
+ seo?: {
+ google_verify?: GoogleVerifyBootstrap;
+ };
+};
+
+/**
+ * Read the Google-verification bootstrap from `window.JetpackScriptData.seo.google_verify`.
+ *
+ * @return The bootstrap, or `null` when unavailable.
+ */
+export function getGoogleVerifyBootstrap(): GoogleVerifyBootstrap | null {
+ const scriptData = getScriptData() as SeoScriptData | undefined;
+ return scriptData?.seo?.google_verify ?? null;
+}
+
+/** `loading` while the initial status request is in flight; `unavailable` when disconnected. */
+export type GoogleVerifyState = 'loading' | 'verified' | 'unverified' | 'unavailable';
+
+export interface GoogleVerify {
+ state: GoogleVerifyState;
+ isConnected: boolean;
+ isOwner: boolean;
+ searchConsoleUrl: string;
+ isVerifying: boolean;
+ /** Open the WordPress.com keyring popup and verify with the returned keyring id. */
+ autoVerify: () => void;
+}
+
+/**
+ * Owns the Google auto-verify flow. On a connected site it fetches the live verified
+ * status on mount, and `autoVerify()` opens the keyring OAuth popup and POSTs the
+ * returned keyring id to verify the site — both via the existing
+ * `/jetpack/v4/verify-site/google` endpoint. On a disconnected site there's no keyring,
+ * so it reports `unavailable` and the UI falls back to manual code entry.
+ *
+ * @return The Google-verification controller.
+ */
+export function useGoogleVerify(): GoogleVerify {
+ const bootstrap = useMemo( () => getGoogleVerifyBootstrap(), [] );
+ const isConnected = bootstrap?.is_connected ?? false;
+ const connectUrl = bootstrap?.connect_url ?? '';
+
+ const [ state, setState ] = useState< GoogleVerifyState >(
+ isConnected ? 'loading' : 'unavailable'
+ );
+ const [ isOwner, setIsOwner ] = useState( false );
+ const [ searchConsoleUrl, setSearchConsoleUrl ] = useState( '' );
+ const [ isVerifying, setIsVerifying ] = useState( false );
+
+ const applyStatus = useCallback( ( status: GoogleVerifyStatus ) => {
+ setState( status.verified ? 'verified' : 'unverified' );
+ setIsOwner( !! status.is_owner );
+ setSearchConsoleUrl( status.google_search_console_url ?? '' );
+ }, [] );
+
+ // Fetch the live verified status once, on a connected site.
+ useEffect( () => {
+ if ( ! isConnected ) {
+ return undefined;
+ }
+ let cancelled = false;
+ apiFetch< GoogleVerifyStatus >( { path: ENDPOINT } )
+ .then( status => {
+ if ( ! cancelled ) {
+ applyStatus( status );
+ }
+ } )
+ .catch( () => {
+ // Treat a failed status check as "not verified" so the verify button
+ // and manual fallback stay available.
+ if ( ! cancelled ) {
+ setState( 'unverified' );
+ }
+ } );
+ return () => {
+ cancelled = true;
+ };
+ }, [ isConnected, applyStatus ] );
+
+ const autoVerify = useCallback( () => {
+ if ( ! connectUrl || isVerifying ) {
+ return;
+ }
+ requestExternalAccess( connectUrl, ( keyringId: number | null ) => {
+ if ( ! keyringId ) {
+ return;
+ }
+ setIsVerifying( true );
+ apiFetch< GoogleVerifyStatus >( {
+ path: ENDPOINT,
+ method: 'POST',
+ data: { keyring_id: keyringId },
+ } )
+ .then( applyStatus )
+ .catch( () => setState( 'unverified' ) )
+ .finally( () => setIsVerifying( false ) );
+ } );
+ }, [ connectUrl, isVerifying, applyStatus ] );
+
+ return { state, isConnected, isOwner, searchConsoleUrl, isVerifying, autoVerify };
+}
diff --git a/projects/packages/seo/_inc/screens/settings/google-verification-field.tsx b/projects/packages/seo/_inc/screens/settings/google-verification-field.tsx
new file mode 100644
index 000000000000..42a5db846e23
--- /dev/null
+++ b/projects/packages/seo/_inc/screens/settings/google-verification-field.tsx
@@ -0,0 +1,116 @@
+/* eslint-disable react/jsx-no-bind */
+
+import { Button, TextControl } from '@wordpress/components';
+import { useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { Badge, Link, Stack } from '@wordpress/ui';
+import { useGoogleVerify } from '../../data/use-google-verify';
+import type { FC } from 'react';
+
+interface Props {
+ /** The manual Google verification code (meta-tag content). */
+ value: string;
+ onChange: ( value: string ) => void;
+ /** Save the manual code — called on blur (auto-save, no Save button). */
+ onCommit?: () => void;
+ disabled?: boolean;
+}
+
+const manualHelp = __(
+ 'Paste the "content" attribute from the Google Search Console meta tag.',
+ 'jetpack-seo'
+);
+
+/**
+ * Google verification: a WordPress.com keyring auto-verify flow on connected sites,
+ * with manual meta-tag entry as a fallback (and the only option when disconnected).
+ * The other services stay simple code fields in the parent card.
+ *
+ * @param props - Component props.
+ * @param props.value - The manual Google verification code.
+ * @param props.onChange - Update the manual code locally.
+ * @param props.onCommit - Save the manual code (on blur).
+ * @param props.disabled - Whether the controls are disabled.
+ * @return The Google verification controls.
+ */
+const GoogleVerificationField: FC< Props > = ( { value, onChange, onCommit, disabled } ) => {
+ const { state, isConnected, isOwner, searchConsoleUrl, isVerifying, autoVerify } =
+ useGoogleVerify();
+ const [ manualOpen, setManualOpen ] = useState( false );
+
+ const manualField = (
+