OpenSEO Google Search Console Integration: A Complete Technical Deep Dive

OpenSEO integrates with Google Search Console (GSC) through a server-side OAuth 2.0 flow using Better-Auth, storing refresh tokens securely and exposing a typed service layer for listing properties, querying search analytics, and inspecting URLs.

The every-app/open-seo repository implements a production-ready GSC integration that keeps OAuth credentials server-side while providing a seamless connect-and-select experience for users. This article breaks down the architecture, key source files, and practical implementation patterns.

How the OAuth Flow Works

OpenSEO's GSC integration begins with a standardized provider registration that both the authentication layer and UI components reference.

Provider Configuration in src/shared/gsc.ts

The integration defines a constant identifier and required OAuth scopes in a shared module:

  • GSC_OAUTH_PROVIDER_ID = "google-search-console" — used across client and server
  • GSC_OAUTH_SCOPES — includes https://www.googleapis.com/auth/webmasters.readonly

This centralization ensures the provider ID stays synchronized between the Better-Auth configuration and any UI components that trigger sign-in.

Better-Auth Integration

The GSC_OAUTH_PROVIDER_ID is consumed in src/lib/auth-config.ts to configure the Google OAuth flow. When a user clicks Connect Google Search Console:

  1. Better-Auth redirects to Google's consent screen
  2. The user grants the requested scopes
  3. Google returns an authorization code
  4. The callback exchanges this for access and refresh tokens
  5. The refresh token persists to the account table with providerId = GSC_OAUTH_PROVIDER_ID

The account table schema (defined in src/db/schema.ts) links the userId to the Google account via accountId, storing the encrypted refresh token for subsequent API calls.

Creating and Using the GSC Client

Low-Level Client: src/server/lib/gscClient.ts

The createGscClient({ userId, gscAccountId? }) function manufactures authenticated Google API clients:

// src/server/lib/gscClient.ts
import { createGscClient } from '@/server/lib/gscClient';

const client = await createGscClient({ 
  userId: 'user_abc123',
  gscAccountId: 'google-account-xyz' // optional, for multi-account users
});

// Available methods
const sites = await client.listSites();
const analytics = await client.querySearchAnalytics({
  siteUrl: 'https://example.com',
  startDate: '2024-01-01',
  endDate: '2024-01-31',
  dimensions: ['query', 'page']
});
const inspection = await client.inspectUrl('https://example.com/blog/post');

The client handles automatic token refresh using the stored refresh token, abstracting OAuth complexity from calling code.

The GscService Orchestration Layer

src/server/features/gsc/services/GscService.ts provides the high-level API that server functions consume. It coordinates grants, validation, and data retrieval across four primary operations.

Listing Sites with Grant Status

listSitesForUserWithGrantStatus(userId) returns every GSC property accessible to the user, annotated with connection health:

// Returns structure:
{
  accounts: [{
    accountId: string;
    email: string;
    requiresReconnect: boolean; // true if refresh token revoked/expired
    sites: [{ siteUrl: string; permissionLevel: 'siteOwner' | 'siteFullUser' | 'siteRestrictedUser' }]
  }]
}

Token errors (401/403) are caught and surfaced as requiresReconnect: true rather than throwing, enabling graceful UI handling.

Validating and Storing a Property Connection

setSite({ projectId, organizationId, siteUrl, accountId, userId }) performs critical validation:

  • Verifies the site URL exists in the user's accessible properties
  • Confirms the user has siteOwner or siteFullUser permission
  • Persists to gscConnections via GscConnectionRepository

The connection record links an OpenSEO project to a single verified GSC property, tracking which Google account created the link.

Fetching Search Performance Data

getPerformance({ projectId, startDate, endDate, dimensions, ... }) builds and executes Search Analytics queries:

// src/serverFunctions/gsc.ts
import { GscService } from '@/server/features/gsc/services/GscService';

export async function getGscPerformance(
  projectId: string, 
  startDate: string, 
  endDate: string
) {
  const performance = await GscService.getPerformance({
    projectId,
    startDate,
    endDate,
    dimensions: ['query', 'page'], // or ['date'], ['country'], etc.
    rowLimit: 5000,
    // Optional filters
    dimensionFilterGroups: [{
      filters: [{
        dimension: 'query',
        operator: 'contains',
        expression: 'open source seo'
      }]
    }]
  });
  
  return performance; 
  // { siteUrl, connectedBy, request: {...}, rows: [{ keys: [...], clicks, impressions, ctr, position }] }
}

Batch URL Inspection

inspectUrls({ projectId, urls, languageCode }) runs URL Inspection API calls in parallel:

// src/serverFunctions/gsc.ts
export async function inspectUrls(projectId: string, urls: string[]) {
  const result = await GscService.inspectUrls({
    projectId,
    urls: [
      'https://example.com/blog/open-seo-guide',
      'https://example.com/pricing'
    ],
    languageCode: 'en'
  });
  
  return result;
  // { 
  //   siteUrl: 'https://example.com',
  //   connectedBy: 'user@example.com',
  //   results: [
  //     { url: '...', result: { inspectionResultLink, indexStatusResult: {...}, mobileUsabilityResult: {...} } },
  //     { url: '...', error: { code, message } } // for API-level failures
  //   ]
  // }
}

Database Schema for GSC Connections

The gscConnections table (src/db/gsc.schema.ts) persists project-property associations:

Column Purpose
id Primary key
projectId Foreign key to OpenSEO project
organizationId Multi-tenancy isolation
siteUrl The GSC property URL (e.g., https://example.com/)
accountId Google account identifier from account table
connectedByUserId OpenSEO user who created the connection
createdAt / updatedAt Audit timestamps

This schema enforces one GSC property per project, with metadata for troubleshooting and audit trails.

Error Handling and Reconnection Flows

Detecting Grant Failures

The isExpectedGrantFailure helper (lines 81-89 in GscService.ts) distinguishes token issues from API errors:

// From GscService.ts
function isExpectedGrantFailure(error: unknown): boolean {
  const code = extractGoogleErrorCode(error);
  return code === 401 || code === 403 || 
         (error instanceof GscTokenError);
}

When detected, the service returns requiresReconnect: true rather than throwing, triggering UI prompts without polluting error logs.

MCP Tools for Admin Operations

src/server/mcp/tools/search-console-tools.ts provides Marketing Control Panel utilities:

  • Connection health checks — verify stored grants are valid
  • Bulk reconnect prompts — surface accounts needing re-authentication
  • Property migration helpers — transfer connections between projects

Server Functions and API Surface

src/serverFunctions/gsc.ts exposes typed endpoints consumed by the frontend:

// Key exports from src/serverFunctions/gsc.ts

export async function listGscSites(userId: string): Promise<GscSiteListResult>;

export async function connectProjectToGsc(
  projectId: string,
  organizationId: string, 
  siteUrl: string,
  accountId: string,
  userId: string
): Promise<GscConnection>;

export async function getGscPerformance(
  projectId: string,
  startDate: string,
  endDate: string,
  dimensions?: SearchAnalyticsDimension[],
  filters?: DimensionFilterGroup[]
): Promise<SearchAnalyticsResponse>;

export async function inspectGscUrls(
  projectId: string,
  urls: string[]
): Promise<UrlInspectionResult>;

These functions enforce authentication, validate organization membership, and delegate to GscService for business logic.

Self-Hosting Considerations

For self-hosted deployments, src/server/lib/self-host-telemetry.ts tracks whether a GSC connection exists:

// Telemetry payload includes
{
  gscConnected: boolean;  // true if any project has an active gscConnection
  // ... other anonymized metrics
}

This enables the SaaS version to show contextual "Connect GSC" onboarding nudges only when relevant, while keeping self-hosted instances' data private.

Complete End-to-End Integration Example

Here's how a typical user interaction flows through the system:

// 1. Client: Initiate OAuth (React component)
'use client';
import { GSC_OAUTH_PROVIDER_ID } from '@/shared/gsc';
import { signIn } from '@/lib/auth-client';

function ConnectButton() {
  return (
    <button onClick={() => signIn(GSC_OAUTH_PROVIDER_ID, { 
      callbackUrl: '/settings/integrations' 
    })}>
      Connect Google Search Console
    </button>
  );
}

// 2. Server: List available properties
// src/app/api/gsc/sites/route.ts
import { listGscSites } from '@/serverFunctions/gsc';

export async function GET() {
  const user = await getCurrentUser();
  const result = await listGscSites(user.id);
  return Response.json(result);
}

// 3. Server: Connect selected property
// src/app/api/gsc/connect/route.ts
import { connectProjectToGsc } from '@/serverFunctions/gsc';

export async function POST(request: Request) {
  const { projectId, siteUrl, accountId } = await request.json();
  const user = await getCurrentUser();
  
  const connection = await connectProjectToGsc(
    projectId,
    user.organizationId,
    siteUrl,
    accountId,
    user.id
  );
  
  return Response.json(connection);
}

Security Architecture

OpenSEO's GSC integration follows OAuth 2.0 best practices:

  • Refresh tokens never reach the client — all API calls execute server-side
  • Token encryption at rest — database stores encrypted credentials
  • Scoped permissions — read-only webmasters.readonly scope limits blast radius
  • Granular authorization — each server function validates organization membership
  • Automatic rotation — Google SDK handles access token refresh transparently

Summary

  • Provider registration: src/shared/gsc.ts centralizes the OAuth provider ID and scopes for consistent reference across client and server
  • Authentication: Better-Auth in src/lib/auth-config.ts handles the OAuth dance, persisting refresh tokens to the account table
  • Client factory: src/server/lib/gscClient.ts creates authenticated Google API clients with automatic token management
  • Service layer: src/server/features/gsc/services/GscService.ts orchestrates site listing, property validation, performance queries, and URL inspection
  • Persistence: src/db/gsc.schema.ts defines the gscConnections table linking projects to verified GSC properties
  • API surface: src/serverFunctions/gsc.ts exposes typed endpoints for frontend consumption
  • Resilience: isExpectedGrantFailure detection and MCP tools provide graceful degradation when tokens expire or are revoked

Frequently Asked Questions

What OAuth scopes does OpenSEO request for Google Search Console?

OpenSEO requests https://www.googleapis.com/auth/webmasters.readonly as defined in src/shared/gsc.ts. This read-only scope allows listing properties, querying search analytics, and inspecting URLs without write access to site configurations.

How does OpenSEO handle expired or revoked refresh tokens?

The isExpectedGrantFailure helper in GscService.ts detects 401/403 responses and GscTokenError instances. Rather than throwing hard errors, these are surfaced as requiresReconnect: true flags in API responses, triggering UI re-authentication prompts without breaking the user experience.

Can a single OpenSEO project connect to multiple GSC properties?

No. The gscConnections schema enforces one property URL per project. The setSite method in GscService upserts the connection record, so selecting a new property replaces the previous association. This design matches the typical use case of one canonical domain per SEO project.

Where are the OAuth tokens stored in a self-hosted deployment?

Refresh tokens are encrypted and stored in the account table (defined in src/db/schema.ts), linked to the user's record via providerId = "google-search-console". The actual encryption and storage mechanisms use the underlying Better-Auth and database configuration, keeping credentials server-side and out of browser localStorage or cookies.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →