How OpenSEO Implements Self-Hosted OAuth for GSC Integration

OpenSEO handles self-hosted OAuth for Google Search Console entirely server-side by generating signed state tokens, exchanging authorization codes for encrypted credentials, and persisting them using BetterAuth's symmetric encryption—eliminating the need for third-party OAuth providers.

OpenSEO connects to Google Search Console using a fully self-hosted OAuth implementation that eliminates external metering costs and third-party dependencies. This architecture manages the complete authorization flow—from initial URL generation to token storage—entirely within your own infrastructure. Understanding how this self-hosted OAuth for GSC integration works is essential for developers deploying OpenSEO in private cloud or on-premises environments.

Prerequisites and Configuration

Before initiating the OAuth flow, OpenSEO validates three required environment variables in src/server/features/gsc/oauth-config.ts:

  • GOOGLE_CLIENT_ID – The OAuth client ID from your Google Cloud Console
  • GOOGLE_CLIENT_SECRET – The corresponding client secret
  • BETTER_AUTH_SECRET – A sufficiently long secret used for token encryption (shared with BetterAuth)

If any of these variables are missing, the GSC connection feature is automatically disabled. According to the source code in oauth-config.ts, this configuration step ensures the OAuth client is properly initialized before any authorization attempts.

Initiating the OAuth Flow

When a user clicks Connect GSC, the server invokes createSelfHostedGscAuthorizationUrl from src/server/features/gsc/selfHostedOAuth.ts (lines 53–82). This function constructs a Google OAuth URL with the following critical parameters:

  • Scopes: openid, email, profile, and https://www.googleapis.com/auth/webmasters.readonly (read-only access)
  • Access type: offline to receive refresh tokens
  • State token: A signed JWT containing the user ID, callback path, and expiration timestamp

The state token is signed using HMAC-SHA-256 with a key derived from the client secret (openseo:gsc:<clientSecret>). This prevents CSRF attacks and ensures the callback belongs to the same user who initiated the request.

import { createSelfHostedGscAuthorizationUrl } from "@/server/features/gsc/selfHostedOAuth";

const authUrl = await createSelfHostedGscAuthorizationUrl({
  user: { userId: "123", userEmail: "alice@example.com" },
  callbackURL: "https://app.mycompany.com/gsc/callback",
  publicOrigin: "https://app.mycompany.com",
});

Handling the OAuth Callback

Google redirects the user to /api/gsc/oauth/callback with code and state parameters. The callback route resolves the appropriate self-hosted context (local-no-auth, Cloudflare Access, etc.) and forwards the request to handleSelfHostedGscOAuthCallback in src/server/features/gsc/selfHostedOAuth.ts (lines 85–135).

State Verification

The handler first verifies the signed state token using verifyState. It validates the HMAC signature, checks expiration, and confirms the user ID matches the authenticated session. If validation fails, the request is rejected immediately.

Token Exchange

Upon successful state verification, the handler exchanges the authorization code for access and refresh tokens via https://oauth2.googleapis.com/token:

const tokens = await exchangeCode({
  code,
  clientId: config.clientId,
  clientSecret: config.clientSecret,
  redirectUri: `${publicOrigin}/api/gsc/oauth/callback`,
});

Persisting the Grant

The system extracts the Google account ID from the ID token and upserts a row in the account table. Tokens are encrypted using symmetricEncrypt from BetterAuth, keyed by BETTER_AUTH_SECRET, ensuring uniform encryption across the application:

await upsertGrant({
  user: { userId: "123", userEmail: "alice@example.com" },
  tokens, // GoogleTokenResponse from exchangeCode()
});

Finally, the user receives a 303 redirect to the original client-side callback path.

Making Authenticated API Calls

Subsequent GSC queries—such as get_search_console_performance and inspect_urls—use src/server/lib/gscClient.ts to retrieve and decrypt the stored tokens. The client loads the encrypted credentials from the database, decrypts them using the shared BETTER_AUTH_SECRET, and attaches them to requests against Google's Search Console APIs.

The grant is scoped per project (one verified property per project) and refreshes automatically through the generic OAuth provider when access tokens expire.

Disconnecting and Cleanup

Removing a GSC connection deletes the property-to-project mapping from gsc_connections. As specified in specs/0003-google-search-console-integration.md (lines 26–28), the encrypted OAuth tokens are only removed from the account table when no other projects reference the same grant, preventing accidental revocation of shared credentials.

Security Architecture

OpenSEO implements multiple layers of protection for self-hosted OAuth:

  • State signing: HMAC-SHA-256 prevents CSRF and fixation attacks
  • Token encryption: Symmetric encryption via BetterAuth ensures credentials remain secure at rest
  • Scope limitation: Only read-only Search Console access is requested, preventing data modification
  • Per-project isolation: The gsc_connections table enforces unique mappings between projects and properties, ensuring workspace members access only their authorized data

Summary

  • OpenSEO's self-hosted OAuth requires three environment variables: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET
  • Authorization URLs include CSRF-protected state tokens signed with HMAC-SHA-256
  • Callback handling verifies state, exchanges codes, and encrypts tokens using BetterAuth's symmetricEncrypt
  • The gscClient.ts library automatically decrypts and uses tokens for API calls, refreshing them as needed
  • Connections are isolated per project, with cleanup logic that preserves shared grants until the last reference is removed

Frequently Asked Questions

What environment variables are required for self-hosted GSC OAuth?

You must configure GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET. The first two come from your Google Cloud Console OAuth credentials, while BETTER_AUTH_SECRET should match the secret used by your BetterAuth instance for consistent encryption across the application.

How does OpenSEO prevent CSRF attacks during the OAuth flow?

The system generates a signed state token containing the user ID and expiration timestamp using HMAC-SHA-256. During the callback in handleSelfHostedGscOAuthCallback, the signature is verified against the client secret. If the state parameter is missing, expired, or tampered with, the authentication is aborted before any token exchange occurs.

How are OAuth tokens stored and encrypted?

Access tokens, refresh tokens, and ID tokens are encrypted using BetterAuth's symmetricEncrypt function with the BETTER_AUTH_SECRET as the encryption key. They are stored in the account table alongside the Google account ID. This approach ensures that even if the database is compromised, the tokens remain encrypted with a key stored separately in the environment.

Can multiple projects share the same GSC connection?

While each project maintains its own entry in gsc_connections for isolation, the underlying OAuth grant in the account table can be shared. When disconnecting GSC from a project, OpenSEO only deletes the encrypted tokens if no other projects reference the same grant, preventing accidental revocation of credentials still needed by other workspaces.

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 →