I was wiring Google Drive into a multi-tenant Payload CMS app and expected OAuth to be the easy part. The Google consent screen worked quickly, but callback persistence failed on tenant/user constraints and then failed again on migration collisions. This guide is the exact implementation that ended up working in production-style conditions, including the fixes for the errors you are most likely to hit.
By the end, you will have a working Google Drive OAuth flow in Payload CMS with:
connect/reconnect/disconnect from admin settings,
tenant-aware connection storage,
token persistence in PostgreSQL,
and a clean callback pipeline that survives real schema constraints.
Step 1: Configure Google Cloud OAuth Correctly
Start in Google Cloud Console with a dedicated project (for example, aArheko) and enable Google Drive API.
Then create OAuth client ID (Web application) and define both JavaScript origins and redirect URIs.
GOOGLE_OAUTH_REDIRECT_URI is optional in this implementation because fallback logic derives it from server URL/origin, but explicit is better for predictability.
Step 3: Install the Google SDK
Install the official Node SDK used by the server routes.
bash
pnpm add googleapis
Step 4: Create a Payload Collection for OAuth Connections
Create a dedicated collection to store provider metadata, user/tenant mapping, and tokens.
This structure gives you two important things. First, relation fields (tenant, user) for admin readability. Second, stable numeric ownership keys (activeTenantId, payloadUserId) for route filtering without relation validation surprises in multi-tenant flows.
Then register this collection in:
src/payload/collections/index.ts
payload.config.ts
src/payload/access-control/visibility.ts
Step 5: Add OAuth Helper Module
Create a helper that centralizes OAuth client creation and token encryption.
The key benefit here is route simplicity. Each route imports the same logic for env parsing and OAuth client creation, so behavior is consistent between local, ngrok, and production.
This is the most important step. It validates state, exchanges the code, and persists the connection. In this implementation we intentionally write both relation and numeric ownership fields because that proved most stable with existing schema constraints.
6.3 Status route
GET /api/integrations/google/status returns a safe payload (connected, email, scopes, expiry, updatedAt) without exposing tokens.
6.4 Disconnect route
POST /api/integrations/google/disconnect attempts token revoke and then marks connection inactive while clearing stored tokens.
Step 7: Add Admin Settings UI
Add an Integrations section under your admin settings and a nested Google page.
The page also reads current connection from Payload and renders account/scopes/expiry so users can see the actual integration state.
Step 8: Run Migrations and Generate Types
Run the standard Payload flow:
bash
pnpm payload generate:types
pnpm payload migrate
If your project script uses cross-env and that package is not present, invoke Payload CLI directly as shown above.
Debugging Notes from Real Failures
This setup hit three real issues during implementation.
First, callback failed with ValidationError: User is invalid. The fix was to introduce payloadUserId and activeTenantId and use those for ownership filters, while still writing relation fields for compatibility.
Second, migration failed because custom tenantId / userId names collided with relation-backed tenant_id / user_id database columns. Renaming to activeTenantId / payloadUserId removed the collision.
Third, callback insert failed because google_connections.user_id was NOT NULL in the existing schema and insert attempted default/null. Explicitly writing user: user.id fixed that insert path immediately.
If you see callback errors with SQL details, decode the details= query param in your redirected URL. It usually reveals the exact column/constraint mismatch in seconds.
Final Checklist
Google Drive API enabled in Google Cloud.
OAuth client configured with exact redirect URIs for all environments.
Env vars set (GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET, redirect URI).
googleapis installed.
google-connections collection registered.
OAuth routes implemented.
Admin integration page wired.
Migrations applied.
Connect flow tested from /admin/settings/integrations/google.
At this point, your OAuth foundation is complete and you can build actual Drive operations (file listing, upload, sync) on top of a stable connection model.
Let me know in the comments if you have questions, and subscribe for more practical development guides.