---
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-07-20"
---

The GSC API [requires OAuth 2.0 authorization](https://developers.google.com/webmaster-tools/v1/how-tos/authorizing). A web-server integration needs a Google Cloud project, an OAuth client, a protected authorization callback, and token-refresh handling. External apps left in Testing status receive [refresh tokens that expire after seven days](https://support.google.com/cloud/answer/15549945?hl=en) when they request the Search Console scope.

## Jump to an Implementation Detail

Skip setup details? Jump to:

- [Token Refresh Implementation](#token-refresh-implementation)
- [Common Revocation Causes](#why-tokens-get-revoked)
- [Testing Mode](#testing-status-and-test-users)

## OAuth 2.0 Flow Overview

1. **Create OAuth credentials** in Google Cloud Console
2. **Redirect the user to Google** for authorization
3. **Receive authorization code** via callback
4. **Exchange code for tokens** (access + refresh)
5. **Store the refresh token** in secure, long-term storage
6. **Refresh the access token** according to the returned `expires_in` value

## 1. Create Google Cloud Project

Open [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

Testing status changes how long refresh tokens remain valid, so set the app's audience deliberately.

Open Google Auth Platform in Cloud Console. Configure the app's Branding, Audience, and Data Access pages.

### User Type Selection

**External**: For apps that can serve Google Accounts outside your Google Workspace organization. In Testing status, only listed test users can authorize non-basic scopes.

**Internal**: Available to projects owned by a Google Workspace organization and limited to users in that organization.

Choose **External** for a public app. Choose **Internal** only when the app is limited to your Google Workspace organization. Google's [Audience documentation](https://support.google.com/cloud/answer/15549945?hl=en) describes both user types and their publishing restrictions.

### App Information

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

### Scopes

On Data Access, add:

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

For read-only Search Console access, use this **minimal scope**. Request `https://www.googleapis.com/auth/webmasters` only if the application must modify Search Console resources. Google's [Search Console authorization guide](https://developers.google.com/webmaster-tools/v1/how-tos/authorizing) lists the read-only and read/write scopes.

### Testing Status and Test Users

Access tokens are short-lived regardless of publishing status. For an External app in [Testing mode](https://support.google.com/cloud/answer/15549945?hl=en) that requests the Search Console scope, the refresh token expires after seven days. Background access then stops until the user authorizes the app again.

You have two options:

1. **Keep the app in Testing** and add up to 100 test users. Search Console refresh tokens will expire after seven days.
2. **Move the app to In production** before serving a public audience.

To publish:

```text
1. Google Auth Platform → Audience
2. Click "Publish App"
3. Complete any branding or data-access verification required for the scopes and audience
```

Google classifies scopes on the Data Access page. Public production apps that request scopes classified as sensitive or restricted must complete the [applicable verification process](https://support.google.com/cloud/answer/13463073?hl=en). Do not assume that a read-only scope is automatically non-sensitive.

## 3. Create OAuth Credentials

Go to Google Auth Platform → Clients and create an OAuth client:

### Application Type

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

**Desktop app**: For an installed command-line or desktop application that runs on a user's computer

Choose the application type that matches where the OAuth flow runs. The examples below use Google's [web-server OAuth flow](https://developers.google.com/identity/protocols/oauth2/web-server), so choose **Web application** if you are following them. A Desktop app uses the installed-application flow and does not follow the redirect-URI setup below.

The raw HTTP examples make the protocol visible, but Google [recommends a supported OAuth client library](https://developers.google.com/identity/protocols/oauth2/web-server#client-libraries) for production integrations. A library handles URL construction, code exchange, and token refresh, but your application still owns state validation, secure storage, and recovery when a grant stops working.

### 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 (for example, `123456.apps.googleusercontent.com`)
- **Client secret**: Confidential credential for the server-side web client (store it securely and never commit it to Git)

Keep the downloaded JSON file outside the repository because it contains the client secret.

## 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 state = crypto.randomUUID()

// Store this in the user's server-side session before redirecting.
await saveOAuthState(state)

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',
  state,
})}`

// Redirect user to authUrl
```

Google documents the full set of [web-server authorization parameters](https://developers.google.com/identity/protocols/oauth2/web-server#httprest_1). Two deserve special attention here:

- `access_type: 'offline'`: Requests a refresh token so the server can obtain new access tokens while the user is absent. Google normally returns it on the first authorization.
- `state`: Binds the callback to the browser session and protects the flow from cross-site request forgery (CSRF). Generate a high-entropy value, store it server-side, and compare it with the callback value before exchanging the code.

`prompt: 'consent'` is optional and forces the consent screen to appear. Use it only for an explicit re-consent flow, not every routine sign-in.

### Exchange Code for Tokens

After the user grants consent, Google redirects to your callback with `?code=...&state=...`. Validate `state` before exchanging the code:

```typescript
async function exchangeCodeForTokens(code: string, existingRefreshToken?: 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()
  const refreshToken = data.refresh_token ?? existingRefreshToken

  if (!refreshToken) {
    throw new Error('No refresh token available; ask the user to re-authorize offline access')
  }

  return {
    accessToken: data.access_token,
    refreshToken,
    expiresIn: data.expires_in,
    expiresAt: Math.floor(Date.now() / 1000) + data.expires_in,
  }
}
```

Before calling this function, compare the callback's `state` value with the one-time value stored in the user's session and reject a missing, mismatched, or reused value. Google's web-server guide instructs applications to [validate the returned state before handling the OAuth response](https://developers.google.com/identity/protocols/oauth2/web-server#handlingresponse).

**Store the refresh token securely.** It is a long-lived credential, but it can expire or be revoked.

## Token Refresh Implementation

Access tokens have limited lifetimes; use `expires_in` from the token response instead of assuming an exact duration. With [offline access](https://developers.google.com/identity/protocols/oauth2/web-server#offline), a refresh token can obtain a new access token while the user is absent. Refresh tokens can still expire or stop working, so 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
}
```

Refresh a few minutes before expiry, as shown above, so an API request does not fail with a 401 first.

## 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: '2026-07-01',
        endDate: '2026-07-07',
        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 long-lived credentials, but Google's [refresh-token expiration documentation](https://developers.google.com/identity/protocols/oauth2#expiration) lists several reasons that they can stop working:

### 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:** Move the app to In production on Google Auth Platform → Audience, and complete any required verification.

### 2. Six-Month Inactivity

**Symptom:** `invalid_grant` error after long periods without API usage.

**Cause:** A refresh token that has not been used for six months is invalidated.

**Fix:** If the user still expects background access, normal jobs should use the refresh token at least once within six months. Do not make extra API calls solely to keep a grant alive.

### 3. Password Reset (Gmail Scope Only)

**Symptom:** Token immediately revoked after user changes password.

**Cause:** If a refresh token includes Gmail scopes (`https://www.googleapis.com/auth/gmail.*`), it can stop working when the user changes their password.

**Fix:** Request Gmail scopes only if the app uses Gmail APIs. `webmasters.readonly` alone is not affected by password changes.

### 4. User Revocation

**Symptom:** An existing grant begins returning `invalid_grant`.

**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."

### 5. Token Limit Exceeded

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

**Cause:** Google [limits a Google Account to 100 live refresh tokens](https://developers.google.com/identity/protocols/oauth2#expiration) per OAuth 2.0 client ID. Issuing token 101 invalidates the oldest token without warning. A separate, larger cross-client limit also applies.

**Fix:** Reuse the stored token instead of forcing consent for routine sign-ins. Revoke obsolete grants when they are no longer needed.

### 6. Time-Limited Access or Administrative Policy

**Cause:** The user granted time-based access and that period ended, or a Google Workspace administrator restricted a service requested by the app.

**Fix:** Offer reauthorization when a time-based grant ends. For an organizational policy restriction, tell the user that they or their administrator must change the policy before reconnecting.

## Error Handling

Common OAuth errors:

### `invalid_grant`

**Causes:**

- Refresh token expired (seven-day Testing status)
- Six-month inactivity
- User revoked access
- Time-based access ended
- Refresh-token limit invalidated an older token

**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
- The configured and requested URL schemes differ (`http://` versus `https://`)

**Response:** Verify that the [redirect URI matches exactly](https://developers.google.com/identity/protocols/oauth2/web-server#httprest_1), including the scheme and trailing slash.

### `ACCESS_TOKEN_SCOPE_INSUFFICIENT`

**Causes:**

- Access token doesn't include `webmasters.readonly` scope
- User did not grant the required permission during granular consent

**Response:** Re-authenticate with correct scope.

## Token Security

### Store Tokens Securely

Google's [OAuth security guidance](https://developers.google.com/identity/protocols/oauth2/resources/best-practices) recommends secure storage for client credentials and encryption at rest for server-side token stores.

**Avoid:**

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

**Use:**

- Use authenticated encryption or a managed key service for tokens at rest
- Store in server-side database
- Store the client secret in a secrets manager or protected server environment

```typescript
// Example: encrypt a token with a 32-byte key from a secrets manager
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'

function encryptToken(token: string, key: Buffer): string {
  if (key.length !== 32) throw new Error('Token encryption key must be 32 bytes')

  const iv = randomBytes(12)
  const cipher = createCipheriv('aes-256-gcm', key, iv)
  const encrypted = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()])
  const authTag = cipher.getAuthTag()

  return [iv, authTag, encrypted].map(value => value.toString('base64url')).join('.')
}

function decryptToken(value: string, key: Buffer): string {
  if (key.length !== 32) throw new Error('Token encryption key must be 32 bytes')

  const [ivValue, tagValue, ciphertextValue] = value.split('.')
  if (!ivValue || !tagValue || !ciphertextValue) throw new Error('Invalid encrypted token')

  const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivValue, 'base64url'))
  decipher.setAuthTag(Buffer.from(tagValue, 'base64url'))

  const plaintext = Buffer.concat([
    decipher.update(Buffer.from(ciphertextValue, 'base64url')),
    decipher.final(),
  ])

  return plaintext.toString('utf8')
}
```

In production, store a key-version identifier with each ciphertext and retain the previous decryption key during a controlled key rotation.

### Rotate Client Secrets

If a client secret is exposed, reset it in Google Auth Platform, deploy the replacement through your secret manager, and retire the compromised value. Creating an entirely new client ID is a larger migration because existing grants belong to the old client.

### Monitor Token Health

Track token refresh failures:

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

Establish a normal failure-rate baseline for your application and alert on a sustained increase. Avoid logging the refresh token or Google's full token response.

## 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_encrypted TEXT NOT NULL,
  refresh_token_encrypted TEXT NOT NULL,
  expires_at INTEGER NOT NULL,
  created_at INTEGER DEFAULT (unixepoch()),
  updated_at INTEGER DEFAULT (unixepoch())
);
```

### Refresh Token Rotation

Google's [refresh response](https://developers.google.com/identity/protocols/oauth2/web-server#offline) normally contains a new access token without a replacement refresh token. Keep the existing refresh token when the response omits `refresh_token`; if a replacement is ever returned, store it atomically.

### Token Scoping

Tokens are associated with the Google Account, OAuth client, and granted scopes. With the read-only Search Console scope, the application can read every Search Console property that the account has permission to access, not only properties it owns; the [Sites list method](https://developers.google.com/webmaster-tools/v1/sites/list) returns those properties and their permission levels.

## Python Refresh Example

```python
import requests
from datetime import datetime, timedelta, timezone

def refresh_token(refresh_token):
    """Refresh an 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',
        },
        timeout=30,
    )
    res.raise_for_status()
    data = res.json()

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

## Related Guides

- [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#connecting-your-gsc-account): Connect Google once, then authenticate MCP clients with a gscdump API key

## Using gscdump Instead

Building this flow yourself means owning consent setup, encrypted token storage, refresh handling, and recovery from revoked grants.

gscdump implements the OAuth client and token-refresh flow for its hosted service:

- Users still authorize their Google Account and choose which properties to connect
- You do not create your own Google Cloud OAuth client for the hosted service
- gscdump stores and refreshes the Google tokens; MCP clients use a separate gscdump API key
- A revoked or expired Google grant still requires the user to reconnect

After connecting, Pro accounts can query their synchronized date range through the [MCP server](/learn-google-search-console/ai-agents/mcp-server). The initial hosted backfill targets up to 180 days, and daily sync jobs are also limited to Pro accounts. Composed reports are unmetered during beta and can query Search Analytics live when stored coverage is unavailable.

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