← Back to Blog

VPS OAuth Survival Guide: Google APIs Without a Browser

February 25, 2026 | By Jingxiao Cai | Updated April 29, 2026
Tags: tutorial, oauth, vps, devops, automation, google-cloud
This post was co-created with Clawsistant, my OpenClaw AI agent. After spending 6 hours fighting OAuth on my VPS, I figured the least the AI could do was help me write about it.
πŸ“ Update (April 2026): Added a fail-closed readiness-gate pattern so OAuth-backed automation stops before side effects when credentials, scopes, or cheap API probes are not healthy. April 29 follow-up: linked that OAuth-specific gate to the broader agent-launch gate pattern.

The Problem: OAuth on a Headless Server

You've set up your Google Cloud project, enabled the APIs, and created OAuth credentials. But now you're stuck:

Error: Unable to open browser automatically. Please visit this URL manually:
https://accounts.google.com/o/oauth2/auth?client_id=...

Your AI agent runs on a VPS (Virtual Private Server) without a browser. The standard OAuth 2.0 flow requires clicking through Google's consent screen in a browserβ€”but there's no browser on your server.

This is the headless OAuth problem. And it's surprisingly common.

In this guide, I'll show you:

Why Standard OAuth Fails on VPS

The typical OAuth 2.0 flow for installed applications looks like this:

1. App generates authorization URL
2. App opens browser to that URL
3. User clicks "Allow" on Google's consent screen
4. Google redirects to localhost with authorization code
5. App exchanges code for access token

Step 2 is the problem. On a headless VPS, there's no browser to open. The run_local_server() function from Google's OAuth library fails immediately.

You might think: "Can't I just SSH with X11 forwarding and run a browser?" Technically yes, but:

Solution Overview: The OAuth Playground Workaround

Google provides an official tool called the OAuth 2.0 Playground that can act as a redirect target for headless servers. Here's how it works:

1. Generate auth URL with OAuth Playground as redirect URI
2. Copy URL from VPS terminal to your LOCAL browser
3. Click "Allow" on Google's consent screen (on your laptop)
4. Google redirects to OAuth Playground with authorization code
5. Copy the code from OAuth Playground back to VPS
6. Exchange code for token via Python script

No browser needed on the VPS. All browser interaction happens on your local machine.

Prerequisites: Before you start, make sure you have:

If you haven't completed the Google Cloud setup, see Google API Setup Guide first.

Step 1: Add OAuth Playground as Redirect URI

Go to your Google Cloud Console:

  1. Navigate to APIs & Services β†’ Credentials
  2. Click on your OAuth 2.0 Client ID
  3. Under Authorized redirect URIs, add:
    https://developers.google.com/oauthplayground
  4. Click Save

Important: This is the official Google OAuth Playground URL. Do NOT use any other redirect URI for this workflow.

πŸ“Έ Screenshot: Google Cloud Console - OAuth Credentials
Shows "Authorized redirect URIs" section with https://developers.google.com/oauthplayground added
(Screenshot to be added: OAuth-credentials-redirect-uri.png)

Step 2: Install Python Dependencies

# Create virtual environment
python3 -m venv venv
source venv/bin/activate

# Install Google Auth libraries + requests (for token exchange)
pip install google-auth google-auth-oauthlib google-auth-httplib2 requests

# Freeze dependencies (for reproducibility)
pip freeze > requirements.txt
πŸ“¦ Complete Scripts Available on GitHub
Instead of copying code from this page, clone the complete repository with both scripts, README, and requirements.txt:
github.com/anyech/openclaw-gmail-reader/oauth

Step 3: Create the OAuth URL Generator Script

The full script is available on GitHub. Here are the key parts:

# Key parameters for headless OAuth
params = {
    'client_id': client_id,
    'redirect_uri': 'https://developers.google.com/oauthplayground',
    'response_type': 'code',
    'access_type': 'offline',  # critical: get refresh token
    'prompt': 'consent',  # critical: always get refresh token
    'scope': ' '.join(SCOPES),
}

# Generate URL
base_url = 'https://accounts.google.com/o/oauth2/auth'
auth_url = f"{base_url}?{urlencode(params)}"

Full script: generate_oauth_url.py on GitHub

Step 4: Create the Code Exchange Script

The full script is available on GitHub. Here are the key parts:

# Token exchange endpoint
token_uri = 'https://oauth2.googleapis.com/token'

# Prepare request
data = {
    'code': auth_code,
    'client_id': client_id,
    'client_secret': client_secret,
    'redirect_uri': REDIRECT_URI,
    'grant_type': 'authorization_code',
}

# Make POST request
response = requests.post(token_uri, data=data)
response.raise_for_status()

token_data = response.json()

# Add metadata and save with secure permissions
token_data['expiry'] = datetime.utcnow().timestamp() + token_data.get('expires_in', 3600)
os.chmod(filename, 0o600)  # Owner read/write only

Full script: exchange_code.py on GitHub

πŸ’‘ Pro Tip: Clone the entire repository to get both scripts, README with full instructions, and requirements.txt:
git clone https://github.com/anyech/openclaw-gmail-reader.git

Step 5: Run the OAuth Flow

Now let's put it all together. If you cloned the repository:

5.1 Generate Authorization URL

cd openclaw-gmail-reader/oauth
source venv/bin/activate
python3 generate_oauth_url.py

5.2 Authorize in Your Local Browser

  1. Copy the URL from the VPS terminal
  2. Paste into your local browser (Chrome, Firefox, Safari on your laptop)
  3. Click "Allow" on Google's consent screen
  4. You'll be redirected to OAuth Playground
πŸ“Έ Screenshot: Google Consent Screen
Shows the Google OAuth consent screen with scopes listed and "Allow" button
(Screenshot to be added: google-consent-screen.png)

5.3 Copy Authorization Code

On the OAuth Playground page, you'll see:

Authorization code: 4/0AY0e-g7ZxKqL9vN8mP3rT6sU2wV5xY8zA1bC4dE7fG

Copy this code (the long string after code=).

πŸ“Έ Screenshot: OAuth Playground - Authorization Code
Shows OAuth Playground page with authorization code displayed in "Step 2" section
(Screenshot to be added: oauth-playground-code.png)

5.4 Exchange Code for Tokens

python3 exchange_code.py 4/0AY0e-g7ZxKqL9vN8mP3rT6sU2wV5xY8zA1bC4dE7fG
βœ… Success! Your token.json file is now ready to use with your AI agent or any Google API application.

Security Best Practices

1. Where to Store Credentials

File Contains Permissions Location
client_secrets.json Client ID + Secret 600 (owner rw) /etc/myapp/ or ~/.config/myapp/
token.json Access + Refresh tokens 600 (owner rw) Same as above
.env API keys, paths 600 (owner rw) App root directory
Never:

2026 Update: Add a Fail-Closed OAuth Readiness Gate

The headless OAuth flow gets you credentials. It does not, by itself, prove that every automated job should run.

A later automation lesson made this stricter for me: if an AI-agent cron job depends on OAuth-backed APIs, the job should perform a cheap readiness gate before it starts doing useful work or sending reports. If the gate fails, the job should stop with a clear credential/scope/readiness error instead of drifting into partial output.

Fail-closed rule: missing credentials, stale scopes, refresh failure, or a failed cheap API probe should block the automation before side effects. Do not let a downstream report pretend the integration is merely empty.

The gate I now prefer checks:

def oauth_readiness_gate(creds, required_scopes, probes):
    if not creds:
        raise SystemExit("oauth readiness failed: credentials missing")
    if not required_scopes.issubset(set(creds.scopes or [])):
        raise SystemExit("oauth readiness failed: scope mismatch")
    if creds.expired and creds.refresh_token:
        creds.refresh(request)
    for name, probe in probes.items():
        if not probe(creds):
            raise SystemExit(f"oauth readiness failed: {name} probe")

This is the same reliability shape as my exact-exec cron driver rule: make the deterministic preconditions explicit before asking an agent wrapper to summarize or deliver anything.

The same gate also applies one layer above OAuth itself. In Fail-Closing Agent Launches, I describe the broader pattern: prove auth intent, isolate unrelated ambient credentials, run cheap route-readiness probes, and block before starting the tool adapter if the launch contract is not healthy.

Troubleshooting

Note: If the flowchart below doesn't render well, view the source on GitHub.
OAuth Flow Fails
    β”‚
    β”œβ”€β–Ά "client_secrets.json not found"
    β”‚   └─▢ Download from Google Cloud Console
    β”‚       (APIs & Services β†’ Credentials β†’ Download)
    β”‚
    β”œβ”€β–Ά "redirect_uri_mismatch"
    β”‚   └─▢ Add https://developers.google.com/oauthplayground
    β”‚       to Authorized redirect URIs in Google Cloud Console
    β”‚
    β”œβ”€β–Ά "Authorization code expired"
    β”‚   └─▢ Codes valid for 10 minutes
    β”‚       Generate new URL and re-authorize
    β”‚
    β”œβ”€β–Ά "Code already used"
    β”‚   └─▢ Authorization codes are single-use
    β”‚       Generate new URL and re-authorize
    β”‚
    β”œβ”€β–Ά "App not verified" warning
    β”‚   └─▢ Click Advanced β†’ Go to (unsafe)
    β”‚       This is normal for personal projects
    β”‚
    └─▢ "Token expired"
        └─▢ Access tokens expire in 1 hour (normal)
            Use refresh token to get new access token
            If refresh token expired (6 months), re-authorize

Troubleshooting: Calendar/Drive 403 Errors (Updated March 2026)

⚠️ Critical Issue Discovered: Calendar and Drive APIs returning 403 "insufficient scopes" even after re-authorization. Don't re-authorize immediately!

Symptoms

Your morning memo or API scripts show:

πŸ“… CALENDAR
⚠️ Auth Error (403) - Calendar API has insufficient authentication scopes

πŸ“ GOOGLE DRIVE
⚠️ Auth Error (403) - Drive API has insufficient authentication scopes

Root Cause: token.json Scope Staleness

The OAuth refresh token may have all scopes, but token.json has stale scope metadata.

When you re-authorize Google OAuth:

  1. βœ… Refresh token is updated with all requested scopes
  2. ❌ token.json file may not be properly saved/updated after refresh
  3. ❌ Python Google library reads stale scope list from token.json
  4. ❌ API calls fail with 403 "insufficient scopes" even though refresh token is valid

The Fix (Without Re-authorizing!)

Step 1: Check Current Token Scopes

cat ~/.openclaw/workspace/gmail-reader/credentials/token.json | \
  python3 -m json.tool | grep -A 10 "scopes"

Step 2: Test APIs with Explicit Scopes

cd ~/.openclaw/workspace/gmail-reader
source venv/bin/activate

# Test Calendar (explicitly requests calendar.readonly scope)
python3 calendar_events.py

# Test Drive
python3 drive_indexer.py

If these work, the refresh token has the scopes β€” just need to update token.json.

Step 3: Update token.json Scopes Manually

cd ~/.openclaw/workspace/gmail-reader
source venv/bin/activate
python3 << 'PYEOF'
from google.oauth2.credentials import Credentials
import json

# Load current token
with open('credentials/token.json', 'r') as f:
    token_data = json.load(f)

print("Current scopes:", token_data.get('scopes'))

# Update with all 5 scopes
new_scopes = [
    'https://www.googleapis.com/auth/gmail.readonly',
    'https://www.googleapis.com/auth/gmail.send',
    'https://www.googleapis.com/auth/calendar.readonly',
    'https://www.googleapis.com/auth/drive.readonly',
    'https://www.googleapis.com/auth/spreadsheets.readonly'
]

token_data['scopes'] = new_scopes

with open('credentials/token.json', 'w') as f:
    json.dump(token_data, f, indent=2)

print("βœ… Updated scopes:", token_data.get('scopes'))
PYEOF

Step 4: Verify Fix

python3 -c "
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build

creds = Credentials.from_authorized_user_file('credentials/token.json')
print('Token scopes:', creds.scopes)

# Test Calendar
cal = build('calendar', 'v3', credentials=creds)
events = cal.events().list(calendarId='primary', maxResults=1).execute()
print('Calendar API: βœ… Working')

# Test Drive
drive = build('drive', 'v3', credentials=creds)
files = drive.files().list(pageSize=1).execute()
print('Drive API: βœ… Working')
"
βœ… Key Lesson: The refresh token is the source of truth, not token.json! Refresh token retains all scopes permanently; token.json scope list can become stale.

Prevention

After any OAuth re-authorization, always:

  1. Verify token.json was saved correctly
  2. Check scopes match what was requested
  3. Test Calendar/Drive APIs immediately

About the Author

Jingxiao Cai is a Principal Architect specializing in ML infrastructure. PhD in Radar Signal Processing from University of Oklahoma. Previously at Oracle building HeatWave ML infrastructure.

Note: This guide was born from pain. After spending 6 hours fighting OAuth on my VPS, I created these scripts so you don't have to. May your tokens never expire unexpectedly.