---
title: "Google Search Console API Authentication"
description: "Set up OAuth 2.0 for GSC API access. Avoid the 7-day testing mode trap, handle token refresh, and understand scope requirements."
canonical_url: "https://gscdump.com/learn-google-search-console/api/authentication"
last_updated: "2026-04-30T09:59:19.411Z"
---

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:

- [Token Refresh Implementation](#token-refresh-implementation)
- [Common Revocation Causes](#why-tokens-get-revoked)
- [Testing Mode Trap](#the-7-day-testing-mode-trap)

## 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](https://console.cloud.google.com/) and create a new project:

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

Enable the Search Console API:

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

## 2. Configure OAuth Consent Screen

**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:

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

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

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

For local development:

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

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

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

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

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

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

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

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

```sql
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

<code-group>

```typescript [TypeScript]
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,
  }
}
```

```python [Python]
import requests
from datetime import datetime, timedelta

def refresh_token(refresh_token):
    """Refresh access token"""
    res = requests.post('https://oauth2.googleapis.com/token', data={
        'client_id': CLIENT_ID,
        'client_secret': CLIENT_SECRET,
        'refresh_token': refresh_token,
        'grant_type': 'refresh_token',
    })
    res.raise_for_status()
    data = res.json()

    return {
        'access_token': data['access_token'],
        'expires_at': datetime.now() + timedelta(seconds=data['expires_in']),
    }
```

</code-group>

## Next Steps

- [Query Builder](/learn-google-search-console/api/query-builder) - Build GSC API requests with filters and dimensions
- [Rate Limits](/learn-google-search-console/api/rate-limits) - Understand quotas and avoid 429 errors
- [gscdump Authentication](/learn-google-search-console/ai-agents/mcp-server#authentication) - Skip OAuth, use gscdump's pre-authenticated API

## 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](/learn-google-search-console/ai-agents/mcp-server).

Try gscdump free: [gscdump.com](https://gscdump.com)
