---
title: "Google Drive OAuth for Payload CMS: Complete Guide"
slug: "setup-google-drive-oauth-payload-cms"
published: "2026-03-13"
updated: "2026-03-01"
categories:
  - "Payload"
tags:
  - "Google Drive OAuth"
  - "Payload CMS OAuth"
  - "Payload CMS Google Drive"
  - "multitenant oauth"
  - "token persistence"
  - "PostgreSQL tokens"
  - "oauth callback handling"
  - "googleapis node"
  - "redirect uri exact match"
  - "refresh token encryption"
  - "admin integrations page"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "payload cms"
  - "google drive api"
  - "googleapis"
  - "postgresql"
  - "node.js"
status: "stable"
llm-purpose: "Google Drive OAuth in Payload CMS: production-tested steps to connect, persist tokens in PostgreSQL, and fix callback/tenant issues—start integrating…"
llm-prereqs:
  - "Access to Payload CMS"
  - "Access to Google Drive API"
  - "Access to googleapis"
  - "Access to PostgreSQL"
  - "Access to Node.js"
llm-outputs:
  - "Completed outcome: Google Drive OAuth in Payload CMS: production-tested steps to connect, persist tokens in PostgreSQL, and fix callback/tenant issues—start integrating…"
---

**Summary Triples**
- (Google Cloud OAuth client, requires exact matching of, Authorized JavaScript origins and Authorized redirect URIs (domain, scheme, and path must match exactly))
- (OAuth scopes, should be minimal and include, openid, email, profile, and the Google Drive scope needed (e.g., https://www.googleapis.com/auth/drive.file))
- (Payload admin integrations, should provide, connect, reconnect, and disconnect controls for Google Drive per tenant)
- (Tokens, must be persisted in, PostgreSQL (access_token, refresh_token, expiry, tenant_id and metadata))
- (Refresh tokens, should be protected by, encryption or secure secrets storage and never logged in plaintext)
- (Callback pipeline, must handle, tenant association and schema/unique-constraint collisions (use upsert/transactional logic))
- (Tenant identification, should be passed via, state parameter in OAuth flow and validated on callback)
- (DB uniqueness, should enforce, unique constraint on (tenant_id, provider) and support upsert on conflict)

### {GOAL}
Google Drive OAuth in Payload CMS: production-tested steps to connect, persist tokens in PostgreSQL, and fix callback/tenant issues—start integrating…

### {PREREQS}
- Access to Payload CMS
- Access to Google Drive API
- Access to googleapis
- Access to PostgreSQL
- Access to Node.js

### {STEPS}
1. Enable Google Drive API
2. Add environment variables
3. Install the googleapis SDK
4. Create google-connections collection
5. Implement OAuth helper module
6. Build OAuth routes (start/callback)
7. Add admin integration UI
8. Run migrations and test

<!-- llm:goal="Google Drive OAuth in Payload CMS: production-tested steps to connect, persist tokens in PostgreSQL, and fix callback/tenant issues—start integrating…" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to Google Drive API" -->
<!-- llm:prereq="Access to googleapis" -->
<!-- llm:prereq="Access to PostgreSQL" -->
<!-- llm:prereq="Access to Node.js" -->
<!-- llm:output="Completed outcome: Google Drive OAuth in Payload CMS: production-tested steps to connect, persist tokens in PostgreSQL, and fix callback/tenant issues—start integrating…" -->

# Google Drive OAuth for Payload CMS: Complete Guide
> Google Drive OAuth in Payload CMS: production-tested steps to connect, persist tokens in PostgreSQL, and fix callback/tenant issues—start integrating…
Matija Žiberna · 2026-03-13

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.

For your setup, this is valid and complete:

- Authorized JavaScript origins:
  - `https://www.arheko.eu`
  - `http://localhost:3000`
  - `https://23e7-46-124-201-0.ngrok-free.app`
- Authorized redirect URIs:
  - `https://www.arheko.eu/api/integrations/google/callback`
  - `https://arheko.eu/api/integrations/google/callback`
  - `http://localhost:3000/api/integrations/google/callback`
  - `https://23e7-46-124-201-0.ngrok-free.app/api/integrations/google/callback`

The critical rule is exact matching. If callback URL differs by domain, scheme, or path, token exchange fails.

On scopes, keep this minimal unless you have a hard requirement for broader access. For this integration, these scopes are enough:

- `openid`
- `email`
- `profile`
- `https://www.googleapis.com/auth/drive.file`
- `https://www.googleapis.com/auth/drive.metadata.readonly`

If you add broad scopes like `drive` or `docs`, you increase review and consent complexity without helping basic integration setup.

## Step 2: Add Environment Variables

Create/update your env file with OAuth credentials.

```bash
# .env.local
GOOGLE_OAUTH_CLIENT_ID=your_client_id.apps.googleusercontent.com
GOOGLE_OAUTH_CLIENT_SECRET=your_client_secret
GOOGLE_OAUTH_REDIRECT_URI=http://localhost:3000/api/integrations/google/callback

# Optional (recommended for production):
GOOGLE_OAUTH_TOKEN_ENCRYPTION_KEY=replace_with_strong_secret
```

`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.

```ts
// File: src/payload/collections/configuration/google-connections/index.ts
import type { CollectionConfig } from 'payload';

export const GoogleConnections: CollectionConfig = {
  slug: 'google-connections',
  indexes: [
    {
      fields: ['provider', 'activeTenantId', 'payloadUserId', 'googleAccountEmail'],
      unique: true,
    },
  ],
  fields: [
    { name: 'provider', type: 'select', required: true, defaultValue: 'google-drive', options: [{ label: 'Google Drive', value: 'google-drive' }] },
    { name: 'tenant', type: 'relationship', relationTo: 'tenants' },
    { name: 'user', type: 'relationship', relationTo: 'users' },
    { name: 'payloadUserId', type: 'number', required: true, index: true },
    { name: 'activeTenantId', type: 'number', required: true, index: true },
    { name: 'googleAccountEmail', type: 'email', required: true, index: true },
    { name: 'googleAccountId', type: 'text' },
    {
      name: 'scopes',
      type: 'array',
      fields: [{ name: 'value', type: 'text', required: true }],
    },
    { name: 'accessToken', type: 'textarea', access: { read: () => false } },
    { name: 'refreshToken', type: 'textarea', access: { read: () => false } },
    { name: 'tokenType', type: 'text' },
    { name: 'expiryDate', type: 'date' },
    { name: 'isActive', type: 'checkbox', required: true, defaultValue: true, index: true },
    { name: 'lastSyncedAt', type: 'date' },
    { name: 'lastError', type: 'textarea' },
  ],
};
```

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.

```ts
// File: src/lib/google/oauth.ts
import crypto from 'node:crypto';
import { google } from 'googleapis';

export const GOOGLE_DRIVE_SCOPES = [
  'openid',
  'email',
  'profile',
  'https://www.googleapis.com/auth/drive.file',
  'https://www.googleapis.com/auth/drive.metadata.readonly',
] as const;

export function getGoogleOAuthEnv(origin?: string) {
  const clientId = (process.env.GOOGLE_OAUTH_CLIENT_ID || '').trim();
  const clientSecret = (process.env.GOOGLE_OAUTH_CLIENT_SECRET || '').trim();
  const configuredRedirectUri = (process.env.GOOGLE_OAUTH_REDIRECT_URI || '').trim();
  const base = (process.env.NEXT_PUBLIC_SERVER_URL || origin || '').replace(/\/$/, '');
  const redirectUri = configuredRedirectUri || `${base}/api/integrations/google/callback`;

  if (!clientId || !clientSecret || !redirectUri) {
    throw new Error('Missing Google OAuth env configuration');
  }

  return { clientId, clientSecret, redirectUri };
}

export function createGoogleOAuthClient(env: { clientId: string; clientSecret: string; redirectUri: string }) {
  return new google.auth.OAuth2(env.clientId, env.clientSecret, env.redirectUri);
}
```

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.

## Step 6: Implement OAuth Routes

You need four routes.

### 6.1 Start route

```ts
// File: src/app/api/integrations/google/start/route.ts
const authorizationUrl = client.generateAuthUrl({
  access_type: 'offline',
  prompt: 'consent',
  scope: [...GOOGLE_DRIVE_SCOPES],
  state,
  include_granted_scopes: true,
});

return NextResponse.redirect(authorizationUrl);
```

This route authenticates the current user, resolves active tenant, stores short-lived cookies (`state`, tenant, `returnTo`), then redirects to Google.

### 6.2 Callback route

```ts
// File: src/app/api/integrations/google/callback/route.ts
const tokenResponse = await client.getToken(code);
client.setCredentials(tokenResponse.tokens);

const profile = await fetchGoogleProfile(client);

const connectionData = {
  provider: 'google-drive' as const,
  user: user.id,
  tenant: tenantId,
  payloadUserId: user.id,
  activeTenantId: tenantId,
  googleAccountEmail: profile.email,
  accessToken: encryptToken(tokens.access_token || null),
  refreshToken: encryptToken(nextRefreshToken),
  tokenType: tokens.token_type || null,
  expiryDate: tokens.expiry_date ? new Date(tokens.expiry_date).toISOString() : null,
  isActive: true,
};
```

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.

```tsx
// File: src/app/admin/settings/integrations/google/page.tsx
<Button asChild>
  <Link href="/api/integrations/google/start?returnTo=/admin/settings/integrations/google">
    {isConnected ? 'Reconnect Google Drive' : 'Connect Google Drive'}
  </Link>
</Button>

{isConnected ? (
  <form method="post" action="/api/integrations/google/disconnect">
    <Button type="submit" variant="outline">Disconnect</Button>
  </form>
) : null}
```

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.

Thanks, Matija

## LLM Response Snippet
```json
{
  "goal": "Google Drive OAuth in Payload CMS: production-tested steps to connect, persist tokens in PostgreSQL, and fix callback/tenant issues—start integrating…",
  "responses": [
    {
      "question": "What does the article \"Google Drive OAuth for Payload CMS: Complete Guide\" cover?",
      "answer": "Google Drive OAuth in Payload CMS: production-tested steps to connect, persist tokens in PostgreSQL, and fix callback/tenant issues—start integrating…"
    }
  ]
}
```