Fixing Permanent OAuth Token Expiration: Moving from Testing to Production OAuth Consent in Google Cloud
The Problem: 7-Day Token Refresh Limits in Testing Mode
Our calendar sync infrastructure kept breaking every week because Google Cloud's OAuth consent screen was stuck in Testing mode. This mode hard-caps refresh token lifetimes to 7 days—meaning every week without active use, the tokens would expire. We'd band-aid it by re-authing manually, but the root cause persisted. This post covers the permanent fix: migrating the OAuth app to Production mode.
Why Testing Mode Exists (And Why It Bit Us)
Google's Testing mode is intentional: it's designed for development workflows where you're iterating on scopes and endpoints frequently. Tokens expire after 7 days of inactivity, forcing developers to re-verify their app regularly. This is fine for a single developer testing locally, but for a production system with automated Lambda invocations and scheduled GAS triggers, it's a blocker.
We had deployed the calendar sync logic across multiple services:
CalendarSync.gs(Google Apps Script) — automated trigger to sync Boatsetter and Sailo bookings- Lambda calendar endpoint — REST API for remote calendar queries
- Apps Script web app deployment — doGet/doPost handlers for booking automation
All three relied on OAuth tokens that were silently expiring every 7 days when unused, causing silent failures in the sync pipeline.
The Fix: Moving to Production OAuth Consent
Step 1: Identify the Current OAuth Configuration
First, verify the current OAuth app state in Google Cloud Console:
gcloud config get-value project
# Returns: jada-sailing-prod (or your project ID)
gcloud auth list
# Verify you're authenticated as an account with Editor+ permissions
The OAuth consent screen settings are UI-only in Cloud Console—they can't be automated via CLI or API. Navigate to:
Google Cloud Console → APIs & Services → OAuth consent screen
You'll see the current status: likely "Testing" with a blue label. This is the setting that enforces the 7-day token limit.
Step 2: Prepare the Publishing Checklist
Before moving to Production mode, Google requires:
- App name: "JADA Sailing Calendar Sync"
- User support email: jadasailing@gmail.com (or your project's support contact)
- Developer contact: Your email address
- Scopes declared: Only the minimum required
https://www.googleapis.com/auth/calendar(read/write bookings)https://www.googleapis.com/auth/gmail.readonly(read vendor emails)https://www.googleapis.com/auth/script.scriptapp(for Apps Script internal calls)
- Privacy policy URL: If public-facing (if your web app is accessible externally, you need one)
- Terms of service URL: (same as above)
In our case, the Apps Script web app is internal-use only (no public users), so privacy/ToS aren't mandatory. But the OAuth app declaration needs to exist.
Step 3: Update OAuth Consent Screen in Cloud Console
Navigate to APIs & Services → OAuth consent screen and click the blue "Publishing status" section:
- Click "Make External" or "Move to Production" (the exact button varies by Console version)
- Fill in the required fields:
- App name: "JADA Sailing Calendar Sync"
- User support email: jadasailing@gmail.com
- Developer contact info: your@email.com
- Review scopes under the "Scopes" section—ensure only the three listed above are present
- Click "Save and Continue"
Once saved, the status should change from "Testing" to "In Production" (or "Production," depending on Console UI version).
Step 4: Re-authorize OAuth Clients
Moving to Production doesn't automatically refresh existing tokens—they're still bound to the old Testing mode. You need to re-authorize each OAuth client:
- Local development: Delete
~/.config/gcloud/credentials.jsonand~/.clasp.json; re-rungcloud auth loginandclasp login - Lambda environment: Retrieve the stored token from DynamoDB or Secrets Manager, delete it, and re-authorize using the same flow (see below)
- Apps Script deployments: The built-in Apps Script OAuth is automatic when you use UrlFetchApp—no manual action needed once the project is in Production mode
To re-authorize Lambda, we used a temporary reauth script (/Users/cb/Documents/repos/tools/reauth_jada_all.py):
#!/usr/bin/env python3
# reauth_jada_all.py - Re-authorize all OAuth clients after moving to Production
import json
import subprocess
import sys
def reauth_gcloud():
"""Re-authorize gcloud CLI."""
print("[*] Re-authorizing gcloud...")
subprocess.run(["gcloud", "auth", "login"], check=True)
def reauth_clasp():
"""Re-authorize clasp (Apps Script CLI)."""
print("[*] Re-authorizing clasp...")
subprocess.run(["clasp", "login"], check=True)
def reauth_lambda():
"""Guide Lambda token update process."""
print("[*] Lambda OAuth token update:")
print(" 1. Obtain a new refresh token via the re-authorization flow")
print(" 2. Store in DynamoDB table: jada-config (key: oauth_calendar_token)")
print(" 3. Update Lambda env var: OAUTH_REFRESH_TOKEN_PARAM")
print(" 4. Redeploy Lambda function")
if __name__ == "__main__":
reauth_gcloud()
reauth_clasp()
reauth_lambda()
print("[+] All OAuth clients re-authorized. Tokens now valid in Production mode.")
Run this script after moving to Production mode to refresh all client credentials.
Step 5: Verify Token Persistence in Production Mode
Once tokens are re-authorized in Production mode, refresh tokens no longer expire after 7 days of inactivity. To verify:
# Check stored token in DynamoDB
aws dynamodb get-item \
--table-name jada-config \
--key '{"param_name": {"S": "oauth_calendar_token"}}' \
--region us-east-1
# Token should exist and remain valid indefinitely (or until explicitly revoked)
Why This Matters for Our Architecture
Our calendar sync pipeline has three components that all depend on persistent OAuth tokens:
- CalendarSync.gs (G