# OpenSEO Google Search Console Integration: A Complete Technical Deep Dive

> Discover how OpenSEO integrates with Google Search Console using OAuth 2.0 and Better-Auth. Access properties, analytics, and URL inspection securely.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: deep-dive
- Published: 2026-09-06

---

**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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/src/server/lib/gscClient.ts)

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

```typescript
// 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`](https://github.com/every-app/open-seo/blob/main/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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/GscService.ts)) distinguishes token issues from API errors:

```typescript
// 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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/src/serverFunctions/gsc.ts) exposes typed endpoints consumed by the frontend:

```typescript
// 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`](https://github.com/every-app/open-seo/blob/main/src/server/lib/self-host-telemetry.ts) tracks whether a GSC connection exists:

```typescript
// 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:

```typescript
// 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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/src/lib/auth-config.ts) handles the OAuth dance, persisting refresh tokens to the `account` table
- **Client factory**: [`src/server/lib/gscClient.ts`](https://github.com/every-app/open-seo/blob/main/src/server/lib/gscClient.ts) creates authenticated Google API clients with automatic token management
- **Service layer**: [`src/server/features/gsc/services/GscService.ts`](https://github.com/every-app/open-seo/blob/main/src/server/features/gsc/services/GscService.ts) orchestrates site listing, property validation, performance queries, and URL inspection
- **Persistence**: [`src/db/gsc.schema.ts`](https://github.com/every-app/open-seo/blob/main/src/db/gsc.schema.ts) defines the `gscConnections` table linking projects to verified GSC properties
- **API surface**: [`src/serverFunctions/gsc.ts`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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`](https://github.com/every-app/open-seo/blob/main/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.