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.
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
- Create OAuth credentials in Google Cloud Console
- Redirect user to Google for consent
- Receive authorization code via callback
- Exchange code for tokens (access + refresh)
- Store refresh token (never expires unless revoked)
- 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
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:
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:
- Add all users as test users (max 100, not scalable)
- 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 tokenprompt: '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://vshttps://mismatch
Response: Verify redirect URI matches exactly (including trailing slashes).
ACCESS_TOKEN_SCOPE_INSUFFICIENT
Causes:
- Access token doesn't include
webmasters.readonlyscope - 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:
- Create new OAuth client ID
- Update app to use new credentials
- 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,
}
}
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']),
}
Next Steps
- Query Builder - Build GSC API requests with filters and dimensions
- Rate Limits - Understand quotas and avoid 429 errors
- gscdump 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.
Try gscdump free: gscdump.com