# How OpenSEO Implements Self-Hosted OAuth for GSC Integration

> Learn how OpenSEO implements self-hosted OAuth for GSC integration server-side with BetterAuth encryption, removing reliance on third-party providers for secure data access.

- Repository: [Every App/open-seo](https://github.com/every-app/open-seo)
- Tags: internals
- Published: 2026-08-05

---

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

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

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

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