Google Search Console API Authentication

Set up OAuth 2.0 for GSC API access. Avoid the 7-day testing mode trap, handle token refresh, and understand scope requirements.

Harlan WiltonHarlan Wilton
1 min

GSC API requires OAuth 2.0 authentication. This means creating a Google Cloud project, configuring OAuth credentials, and implementing token refresh logic. The process is straightforward, but the 7-day testing mode trap catches most developers.

Quick Start

Skip setup details? Jump to:

OAuth 2.0 Flow Overview

  1. Create OAuth credentials in Google Cloud Console
  2. Redirect user to Google for consent
  3. Receive authorization code via callback
  4. Exchange code for tokens (access + refresh)
  5. Store refresh token (never expires unless revoked)
  6. Refresh access token hourly (expires every 60 minutes)

1. Create Google Cloud Project

Navigate to Google Cloud Console and create a new project:

1. Click "Select a Project" → "New Project"
2. Name: "GSC API Integration" (or your app name)
3. Click Create

Enable the Search Console API:

1. Go to APIs & Services → Library
2. Search "Google Search Console API"
3. Click Enable

This is where the 7-day trap happens. Pay close attention.

Go to APIs & Services → OAuth consent screen:

User Type Selection

External: For public apps. Anyone with a Google account can authenticate.

Internal: For Google Workspace orgs only. Users must be in your workspace.

Choose External unless you're building an internal tool.

App Information

  • App name: Your app name (shown during consent)
  • User support email: Your email
  • Developer contact: Your email

Click Save and Continue.

Scopes

Click "Add or Remove Scopes" and add:

https://www.googleapis.com/auth/webmasters.readonly

This is the minimal scope for GSC API read-only access. Click Save and Continue.

Test Users (CRITICAL STEP)

If you leave your app in "Testing" mode:

  • Tokens expire after 7 days (not just access tokens, refresh tokens too)
  • You'll need to re-authenticate weekly
  • Your app will break in production

Solution: Either:

  1. Add all users as test users (max 100, not scalable)
  2. Publish the app (recommended for production)

To publish:

1. OAuth consent screen → Publishing status
2. Click "Publish App"
3. Confirm you're ready for production

Note: Google may prompt for verification if your app requests sensitive scopes. webmasters.readonly is non-sensitive and doesn't require verification for most apps.

3. Create OAuth Credentials

Go to APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client ID:

Application Type

Web application: For server-side apps (Node, Python, etc.)

Desktop app: For local scripts (avoid unless testing)

Choose Web application.

Authorized Redirect URIs

Add your OAuth callback URL:

https://yourapp.com/auth/google/callback

For local development:

http://localhost:3000/auth/google/callback

Click Create. You'll receive:

  • Client ID: Public identifier (e.g., 123456.apps.googleusercontent.com)
  • Client Secret: Private key (store securely, never commit to git)

Download the JSON file and store it safely.

4. Implement OAuth Flow

Authorization URL

Redirect users to Google's OAuth endpoint:

const clientId = '123456.apps.googleusercontent.com'
const redirectUri = 'https://yourapp.com/auth/google/callback'
const scope = 'https://www.googleapis.com/auth/webmasters.readonly'

const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${new URLSearchParams({
  client_id: clientId,
  redirect_uri: redirectUri,
  response_type: 'code',
  scope,
  access_type: 'offline', // Critical: get refresh token
  prompt: 'consent', // Force consent to ensure refresh token
})}`

// Redirect user to authUrl

Critical parameters:

  • access_type: 'offline': Required to receive refresh token
  • prompt: 'consent': Forces consent screen even for returning users (ensures refresh token is returned)

Exchange Code for Tokens

After user grants consent, Google redirects to your callback with ?code=...:

async function exchangeCodeForTokens(code: string) {
  const res = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: clientId,
      client_secret: clientSecret,
      code,
      redirect_uri: redirectUri,
      grant_type: 'authorization_code',
    }),
  })

  if (!res.ok) {
    const error = await res.text()
    throw new Error(`Token exchange failed: ${error}`)
  }

  const data = await res.json()
  return {
    accessToken: data.access_token,
    refreshToken: data.refresh_token, // Store this permanently
    expiresIn: data.expires_in, // Typically 3600 (1 hour)
    expiresAt: Math.floor(Date.now() / 1000) + data.expires_in,
  }
}

Store the refresh token securely. It's the key to long-term API access.

Token Refresh Implementation

Access tokens expire after 60 minutes. Refresh tokens do not expire (unless revoked). Implement automatic refresh:

interface Tokens {
  accessToken: string
  refreshToken: string
  expiresAt: number // Unix timestamp
}

async function refreshAccessToken(refreshToken: string): Promise<{ accessToken: string, expiresAt: number }> {
  const res = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: clientId,
      client_secret: clientSecret,
      refresh_token: refreshToken,
      grant_type: 'refresh_token',
    }),
  })

  if (!res.ok) {
    const error = await res.json()
    throw new Error(`Token refresh failed: ${error.error_description || error.error}`)
  }

  const data = await res.json()
  return {
    accessToken: data.access_token,
    expiresAt: Math.floor(Date.now() / 1000) + data.expires_in,
  }
}

async function getValidAccessToken(tokens: Tokens): Promise<string> {
  const now = Math.floor(Date.now() / 1000)

  // Refresh 5 minutes before expiry (buffer for clock skew)
  if (tokens.expiresAt - now < 300) {
    const refreshed = await refreshAccessToken(tokens.refreshToken)
    tokens.accessToken = refreshed.accessToken
    tokens.expiresAt = refreshed.expiresAt

    // Save updated tokens to database
    await saveTokens(tokens)
  }

  return tokens.accessToken
}

Best practice: Refresh proactively (before expiry) rather than reactively (after 401 error).

Making Authenticated Requests

Use the access token in API requests:

async function queryGSC(siteUrl: string, tokens: Tokens) {
  const accessToken = await getValidAccessToken(tokens)

  const res = await fetch(
    `https://searchconsole.googleapis.com/webmasters/v3/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`,
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        startDate: '2025-01-20',
        endDate: '2025-01-26',
        dimensions: ['query'],
        rowLimit: 25000,
      }),
    },
  )

  if (!res.ok) {
    throw new Error(`GSC API error: ${res.status} ${await res.text()}`)
  }

  return res.json()
}

Why Tokens Get Revoked

Refresh tokens are permanent, until they're not. Google revokes tokens for:

1. App Still in Testing Mode

Symptom: Tokens stop working after exactly 7 days.

Cause: OAuth consent screen set to "Testing" with "External" user type.

Fix: Publish app (OAuth consent screen → Publish App).

2. Six-Month Inactivity

Symptom: invalid_grant error after long periods without API usage.

Cause: Refresh tokens not used for 6+ months are automatically invalidated.

Fix: Query GSC API at least once every 6 months per user. Implement monthly health checks:

async function keepTokenAlive(tokens: Tokens, siteUrl: string) {
  // Query minimal data to refresh token
  await querySearchAnalytics(tokens, siteUrl, {
    startDate: '2025-01-01',
    endDate: '2025-01-01',
    dimensions: ['date'],
    rowLimit: 1,
  })
}

3. Enterprise Session Control

Symptom: invalid_grant error after 1-24 hours for Google Workspace users.

Cause: Enterprise admin enabled "Session control for Google services" policy.

Fix: None from your side. User must disable policy or re-authenticate frequently.

Detection: Look for Google\\Service\\Exception with message "invalid_grant". These users require manual handling.

4. Password Reset (Gmail Scope Only)

Symptom: Token immediately revoked after user changes password.

Cause: If your OAuth scope includes Gmail access (https://www.googleapis.com/auth/gmail.*), Google revokes all tokens when password changes.

Fix: Don't request Gmail scopes unless absolutely required. webmasters.readonly alone is not affected by password changes.

5. User Revocation

Symptom: invalid_grant error at random times.

Cause: User manually revoked access at myaccount.google.com/permissions.

Fix: Prompt re-authentication. Show clear error message: "Access revoked. Please reconnect your Google account."

6. Token Limit Exceeded

Symptom: Oldest tokens stop working when user authenticates on many devices.

Cause: Google limits active refresh tokens per user per OAuth app. Oldest tokens are invalidated when limit is reached (~25 tokens for most apps, ~50 for verified apps).

Fix: Track active tokens per user. Revoke old tokens when issuing new ones.

7. Normal Revocation Rate

Expected: ~1% monthly revocation rate across all users.

Cause: Combination of bugs, edge cases, and undocumented Google behaviors.

Strategy: Implement graceful re-authentication flow. Don't treat revocation as error: expect it.

Error Handling

Common OAuth errors:

invalid_grant

Causes:

  • Refresh token expired (7-day testing mode)
  • 6-month inactivity
  • User revoked access
  • Enterprise session control

Response: Prompt re-authentication.

invalid_client

Causes:

  • Wrong client ID or client secret
  • Credentials deleted in Google Cloud Console

Response: Check credentials, regenerate if needed.

redirect_uri_mismatch

Causes:

  • Callback URL doesn't match authorized redirect URIs in Google Cloud Console
  • Missing http:// vs https:// mismatch

Response: Verify redirect URI matches exactly (including trailing slashes).

ACCESS_TOKEN_SCOPE_INSUFFICIENT

Causes:

  • Access token doesn't include webmasters.readonly scope
  • User de-scoped permissions during consent

Response: Re-authenticate with correct scope.

Security Best Practices

Store Tokens Securely

Never:

  • Commit tokens to git
  • Log tokens to console/files
  • Store in browser localStorage (XSS risk)

Do:

  • Encrypt tokens at rest (AES-256)
  • Store in server-side database
  • Use environment variables for client secret
// Example: Encrypt refresh token before storage
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'

function encryptToken(token: string, secret: string): string {
  const iv = randomBytes(16)
  const cipher = createCipheriv('aes-256-cbc', Buffer.from(secret, 'hex'), iv)
  const encrypted = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()])
  return `${iv.toString('hex')}:${encrypted.toString('hex')}`
}

function decryptToken(encryptedToken: string, secret: string): string {
  const [ivHex, encryptedHex] = encryptedToken.split(':')
  const iv = Buffer.from(ivHex, 'hex')
  const encrypted = Buffer.from(encryptedHex, 'hex')
  const decipher = createDecipheriv('aes-256-cbc', Buffer.from(secret, 'hex'), iv)
  return decipher.update(encrypted) + decipher.final('utf8')
}

Rotate Client Secrets

Google allows creating multiple OAuth credentials. Rotate secrets annually:

  1. Create new OAuth client ID
  2. Update app to use new credentials
  3. Delete old credentials after migration

Monitor Token Health

Track token refresh failures:

async function refreshWithMonitoring(refreshToken: string) {
  try {
    return await refreshAccessToken(refreshToken)
  }
  catch (err) {
    // Log to monitoring system
    logError('token_refresh_failed', {
      error: err.message,
      userId: getCurrentUserId(),
    })
    throw err
  }
}

Alert when refresh failure rate exceeds 5% (indicates systematic issue).

The 7-Day Testing Mode Trap

This is the #1 OAuth gotcha. Worth repeating:

If your OAuth app status is "Testing" with "External" user type:

  • Access tokens expire after 1 hour (normal)
  • Refresh tokens expire after 7 days (abnormal)
  • Users must re-authenticate weekly
  • Production apps will break

Fix: OAuth consent screen → Publish App.

Verification: Check app status shows "In production" not "Testing".

Multi-User Considerations

Per-User Token Storage

Store tokens per user:

CREATE TABLE user_tokens (
  user_id INTEGER PRIMARY KEY,
  google_id TEXT UNIQUE NOT NULL,
  access_token TEXT NOT NULL,
  refresh_token TEXT NOT NULL,
  expires_at INTEGER NOT NULL,
  created_at INTEGER DEFAULT (unixepoch()),
  updated_at INTEGER DEFAULT (unixepoch())
);

Refresh Token Rotation

Some OAuth providers rotate refresh tokens on each use. Google does not: refresh tokens remain valid until revoked.

Token Scoping

Tokens are scoped to the Google account, not GSC property. One token grants access to all properties the user owns.

Implementation Examples

async function refreshAccessToken(refreshToken: string): Promise<{ accessToken: string, expiresAt: number }> {
  const res = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: clientId,
      client_secret: clientSecret,
      refresh_token: refreshToken,
      grant_type: 'refresh_token',
    }),
  })

  if (!res.ok) {
    const error = await res.json()
    throw new Error(`Token refresh failed: ${error.error_description || error.error}`)
  }

  const data = await res.json()
  return {
    accessToken: data.access_token,
    expiresAt: Math.floor(Date.now() / 1000) + data.expires_in,
  }
}

Next Steps

Why gscdump Exists

OAuth implementation is complex. Token management adds operational overhead. Debugging revocation issues wastes time.

gscdump handles authentication for you:

  • No OAuth setup required
  • No token refresh logic
  • No 7-day testing mode trap
  • No revocation monitoring

Connect once, query forever via MCP server.

Try gscdump free: gscdump.com

gscdump
© 2026 GSCDUMP.COM - BUILT FOR DEVELOPERS