This guide outlines a structured approach to implementing a custom OAuth authentication strategy in Payload CMS, using Google as a reference provider. The intent is to explain how Payload expects authentication data to be introduced into its system and how external OAuth providers can be integrated without bypassing Payload’s internal controls.
Rather than positioning OAuth as a standalone integration, this guide treats it as an identity handoff mechanism. Payload remains responsible for user resolution, session issuance, and permission enforcement, while OAuth providers act only as external identity sources.
This document has not been scoped to create users, this can easily be achieved within the auth strategy after the initial payload.find().
Structure
Payload does not initiate OAuth flows or communicate directly with external identity providers. Its authentication model assumes that any external identity has already been validated and is presented to Payload in a defined format.
To support this model, OAuth must be implemented as a controlled bridge around Payload’s auth() pipeline. This bridge is responsible for managing cryptographic state, handling redirects, validating provider responses, and translating OAuth data into a form Payload can process.
This separation is intentional. It limits the blast radius of third-party integrations and ensures that Payload remains the single authority for authentication and session management.
Layers
A complete OAuth implementation in Payload is composed of four required layers. Each layer performs a specific function and depends on the correct execution of the previous layer.
- Authorisation endpoint
- Callback endpoint
- Authentication strategy
- Client trigger
Payload will not authenticate a user unless all four layers exist and are aligned. When any layer is missing or misconfigured, failures typically present as silent redirects or incomplete sessions rather than explicit errors.
Google Cloud
Before implementing Google OAuth, a Google Cloud project must be created and configured with OAuth credentials and authorised redirect URIs.
Create OAuth client

Use the Google Cloud Console to create an OAuth client for a server-side web application.
- Create a new OAuth client ID and select Web application as the application type.
- Assign an internal name to identify the client within Google Cloud.
- Under Authorised redirect URIs, add the Payload OAuth callback endpoint for each environment, including local development and production.
- Save the client to generate the OAuth credentials.

Once the client is created, Google issues the credentials required to complete the OAuth flow.
- Copy the generated Client ID and Client Secret immediately, as secrets may not be retrievable later.
- Store both values securely as environment variables and never commit them to source control.
- Use these credentials in your server-side authorization and callback endpoints to exchange authorization codes for tokens.
GOOGLE_BUSINESS_CLIENT_ID=Client ID
GOOGLE_BUSINESS_CLIENT_SECRET=Client Secret
NEXT_PUBLIC_SERVER_HOSTNAME=localhost or 'yourdomain.com'PKCE
OAuth flows that use PKCE (Proof Key for Code Exchange) rely on a pair of cryptographically related values to protect against authorization code interception.
- A code verifier, generated using high-entropy randomness
- A code challenge, derived by hashing the verifier with SHA-256 and encoding it in a URL-safe format
Both values must be generated server-side. The verifier must be persisted temporarily so it can be recovered during the callback phase. At no point should these values be exposed to the client.
To enforce this boundary, PKCE logic should be isolated into a small utility module responsible only for generation and encoding. This keeps cryptographic concerns separate from routing and provider logic and simplifies audit and reuse.
lib/auth/pke.ts
import crypto from 'crypto'
export enum CodeChallengeMethod {
Plain = 'plain',
S256 = 'S256',
}
export function generateCodeChallenge(codeVerifier: string) {
const hash = crypto.createHash('sha256').update(codeVerifier).digest()
return base64URLEncode(hash)
}
export function generateCodeVerifier() {
return base64URLEncode(crypto.randomBytes(32))
}
function base64URLEncode(buffer: Buffer) {
return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}Cookies
This module centralises cookie behaviour for all OAuth flows, ensuring consistent security attributes and making it easy to manage short-lived, single-use cookies across endpoints.
lib/auth/cookies.ts
const isProduction = process.env.NODE_ENV === 'production'
const hostname = process.env.NEXT_PUBLIC_SERVER_HOSTNAME!
const domain =
isProduction && hostname ? hostname.replace(/^https?:\/\//, '').split(':')[0] : undefined
const cookieBase = [
'Path=/',
'HttpOnly',
'SameSite=Lax',
isProduction ? 'Secure' : '',
domain ? `Domain=${domain}` : '',
].filter(Boolean)
// Appends a Set-Cookie header without overwriting existing cookies.
export function appendCookie(
headers: Headers,
name: string,
value: string,
maxAge = 180
) {
headers.append(
'Set-Cookie',
[
`${name}=${value}`,
...cookieBase,
`Max-Age=${maxAge}`,
].join('; ')
)
}
// Invalidates a cookie by name using matching attributes.
export function clearCookie(
headers: Headers,
name: string,
) {
headers.append(
'Set-Cookie',
[
`${name}=`,
...cookieBase,
'Max-Age=0',
'Expires=Thu, 01 Jan 1970 00:00:00 GMT',
].join('; ')
)
}Authorisation
The authorisation endpoint initiates the OAuth flow. It does not authenticate users and does not interact with Payload directly.
Its role is to prepare and persist the temporary state required for OAuth, then redirect the user to the provider. Specifically, it is responsible for:
- Generating PKCE verifier and challenge values
- Constructing the provider authorization URL
- Persisting temporary state using HTTP-only cookies
- Returning either a redirect response or a JSON payload containing the authorization URL
For Google, this endpoint is exposed at /api/users/auth/google.
Cookies are short-lived, scoped conservatively, and cleared after use. They exist solely to link the authorization request to the callback response and should never persist beyond that window.
Endpoint: /api/users/auth/google
import crypto from 'crypto'
import { google } from 'googleapis'
import { type Endpoint, type PayloadRequest } from 'payload'
import { appendCookie } from '@/lib/auth/cookie'
import { CodeChallengeMethod, generateCodeChallenge, generateCodeVerifier } from '@/lib/auth/pke'
import { getServerSideURL } from '@/utils/getURL'
const clientId = process.env.GOOGLE_BUSINESS_CLIENT_ID!
const clientSecret = process.env.GOOGLE_BUSINESS_CLIENT_SECRET!
export const googleAuth: Endpoint = {
handler: async (req: PayloadRequest): Promise<Response> => {
const url = new URL(req.url ?? '')
const consentFlag = url.searchParams.get('force_consent') === 'true'
// The query parameter name (`client_login`) is arbitrary and can be changed
// Example: https://example.com/login?client_login=true
// This flag is later used to determine whether the user is redirected
// to a client-facing route or the admin interface after authentication.
const clientFlag = url.searchParams.get('client_login') === 'true'
const oauth2Client = new google.auth.OAuth2({
clientId,
clientSecret,
redirectUri: `${getServerSideURL()}/api/users/auth/google/callback`,
})
try {
const codeVerifier = generateCodeVerifier()
const codeChallenge = generateCodeChallenge(codeVerifier)
const state = crypto.randomUUID()
const authUrl = oauth2Client.generateAuthUrl({
access_type: 'offline',
code_challenge: codeChallenge,
code_challenge_method: CodeChallengeMethod.S256,
prompt: consentFlag ? 'consent' : 'none',
scope: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
],
state,
})
// codeVerifier: Used for PKCE validation during the token exchange
// oauthState: Validated on callback to prevent CSRF and code injection
// clientFlag: Carries routing intent so we know where to redirect the user
const headers = new Headers()
appendCookie(headers, 'codeVerifier', codeVerifier)
appendCookie(headers, 'oauthState', state)
appendCookie(headers, 'clientFlag', clientFlag ? 'true' : '')
headers.set('Location', authUrl.toString())
return new Response(null, {
headers,
status: 302,
})
} catch {
const headers = new Headers()
headers.set(
'Location',
`${getServerSideURL()}/${clientFlag ? 'client' : 'admin/login'}?error=oauth_failed`
)
return new Response(null, {
headers,
status: 302,
})
}
},
method: 'get',
path: '/auth/google',
}Callback
The callback endpoint is where control returns to the application after the OAuth provider completes authentication.
This endpoint validates the provider response and hands off identity resolution to Payload. Its responsibilities include:
- Parsing authorisation codes and error parameters
- Recovering the PKCE verifier and strategy metadata from cookies
- Clearing all temporary cookies, regardless of outcome
- Calling
payload.auth()with OAuth data supplied via request headers
Payload does not accept OAuth data through query parameters or request bodies. All OAuth context must be provided through headers so it can be processed by the registered authentication strategy.
If Payload successfully resolves a user, the callback endpoint proceeds to issue a signed session token, generate Payload authentication cookies, and redirect the user into the authenticated admin interface. If any step fails, the user is redirected with an explicit error state and no residual authentication data.
Endpoint: /api/users/auth/google/callback
import crypto from 'crypto'
import {
type Endpoint,
generatePayloadCookie,
jwtSign,
parseCookies,
type PayloadRequest,
type SanitizedPermissions,
type TypedUser,
} from 'payload'
import { clearCookie } from '@/lib/auth/cookie'
import { getServerSideURL } from '@/utils/getURL'
interface AuthResult {
permissions: SanitizedPermissions
responseHeaders?: Headers
user:
| null
| (TypedUser & {
_sid?: string
_strategy?: string
collection: 'users'
})
}
export const googleCallback: Endpoint = {
handler: async (req: PayloadRequest): Promise<Response> => {
const url = new URL(req.url ?? getServerSideURL())
const code = url.searchParams.get('code')
const error = url.searchParams.get('error')
const state = url.searchParams.get('state')
const cookie = parseCookies(req.headers)
const codeVerifier = cookie.get('codeVerifier')
const oauthState = cookie.get('oauthState')
const clientFlag = cookie.get('clientFlag') === 'true'
// Clear temporary OAuth cookies after they have been consumed.
// This enforces single-use semantics for the login flow
const headers = new Headers()
clearCookie(headers, 'codeVerifier')
clearCookie(headers, 'oauthState')
clearCookie(headers, 'clientFlag')
if (
error === 'interaction_required' ||
error === 'login_required' ||
error === 'consent_required'
) {
headers.set(
'Location',
`${getServerSideURL()}/api/users/auth/google?force_consent=true${clientFlag ? '&client_login=true' : ''}`
)
return new Response(null, {
headers,
status: 302,
})
}
const errorRedirect = (reason: string) => {
headers.set(
'Location',
// 'client' can be adjusted to your client-facing login.
`${getServerSideURL()}/${clientFlag ? 'client' : 'admin/login'}?error=${reason}`
)
return new Response(null, {
headers,
status: 302,
})
}
if (!state || !oauthState || state !== oauthState) {
console.error('Invalid OAuth state', { oauthState, state })
return errorRedirect('invalid_state')
}
if (!code || !codeVerifier) {
console.error('Missing OAuth parameters', { code, codeVerifier })
return errorRedirect('missing_parameters')
}
try {
const payload = req.payload
const authResult = await payload.auth({
headers: new Headers({
'x-auth-strategy': 'google',
'x-oauth-code': code,
'x-oauth-verifier': codeVerifier,
}),
})
const { user } = authResult as AuthResult
if (!user) {
console.error('Authentication failed: No user returned')
return errorRedirect('auth_failed')
}
const collection = payload.collections['users']
const authConfig = collection.config.auth
const secret = crypto
.createHash('sha256')
.update(payload.config.secret)
.digest('hex')
.slice(0, 32)
const { token } = await jwtSign({
fieldsToSign: {
_strategy: user._strategy ?? undefined,
collection: 'users',
email: user.email,
id: user.id,
sid: user._sid ?? undefined,
},
secret,
tokenExpiration: authConfig.tokenExpiration,
})
const cookies = generatePayloadCookie({
collectionAuthConfig: authConfig,
cookiePrefix: payload.config.cookiePrefix,
token: token!,
})
// 'client' can be adjusted to your client-facing dashboard.
headers.set('Location', `${getServerSideURL()}/${clientFlag ? 'client' : 'admin'}`)
headers.append('Set-Cookie', cookies)
return new Response(null, {
headers,
status: 302,
})
} catch {
return errorRedirect('oauth_failed')
}
},
method: 'get',
path: '/auth/google/callback',
}Auth strategy
The authentication strategy is where external OAuth identity is converted into a Payload user.
This strategy is registered in the Payload configuration and is invoked internally when payload.auth() is called with a matching strategy identifier. It is responsible for:
- Reading OAuth metadata from request headers
- Exchanging the authorisation code for access tokens
- Fetching the provider’s user profile
- Normalising provider identity fields
- Resolving or creating a Payload user record
- Returning a
{ user }object to Payload
Payload will not issue a session unless this strategy returns a valid user. All provider communication and token handling must remain encapsulated within this layer to avoid leaking OAuth concerns into the wider application.
import crypto from 'crypto'
import { google } from 'googleapis'
import { type AuthStrategy, type AuthStrategyResult } from 'payload'
import { type User } from '@/payload/payload-types'
import { getServerSideURL } from '@/utils/getURL'
import { mergeAuth } from '@/utils/mergeAuth'
interface SessionUser extends User {
_sid?: string
_strategy?: string
collection: 'users'
}
const clientId = process.env.GOOGLE_BUSINESS_CLIENT_ID!
const clientSecret = process.env.GOOGLE_BUSINESS_CLIENT_SECRET!
export const googleStrategy: AuthStrategy = {
async authenticate({ headers, payload }): Promise<AuthStrategyResult> {
const code = headers.get('x-oauth-code')
const codeVerifier = headers.get('x-oauth-verifier')
const strategy = headers.get('x-auth-strategy')
const provider = 'google' as const
if (strategy !== provider) return { user: null }
if (!code || !codeVerifier) return { user: null }
try {
// Initialize OAuth2 client with credentials
const oauth2Client = new google.auth.OAuth2({
clientId,
clientSecret,
redirectUri: `${getServerSideURL()}/api/users/auth/google/callback`,
})
// Exchange authorization code for tokens
const { tokens } = await oauth2Client.getToken({ code, codeVerifier })
if (!tokens.id_token) return { user: null }
// Verify ID token authenticity and extract user info
const verify = await oauth2Client.verifyIdToken({
audience: clientId,
idToken: tokens.id_token,
})
const userInfo = verify.getPayload()
if (!userInfo) return { user: null }
// Ensure email is verified by Google
if (!userInfo.email_verified) {
return { user: null }
}
// Extract unique identifiers from verified token
const sub = userInfo.sub
const email = userInfo.email?.toLowerCase()
if (!sub || !email) return { user: null }
// Find existing user by email
const user = (
await payload.find({
collection: 'users',
limit: 1,
pagination: false,
showHiddenFields: true,
where: {
email: {
equals: email,
},
},
})
).docs[0] as SessionUser
if (!user) return { user: null }
// Check for existing Google authentication
const existingStrategies = user.externalId?.authStrategies ?? []
const existing = existingStrategies.find((s) => s.authProvider === provider)
// Prevent account takeover by validating provider user ID
if (existing?.providerUserId && existing.providerUserId !== sub) return { user: null }
const collection = payload.collections['users']
const authConfig = collection.config.auth
// Create new session if session management is enabled
let sid: string | undefined
const now = new Date()
const tokenExpInMs = (authConfig.tokenExpiration || 7200) * 1000
const expiresAt = new Date(now.getTime() + tokenExpInMs)
if (authConfig.useSessions) {
sid = crypto.randomUUID()
const session = {
createdAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
id: sid,
}
// Remove expired sessions and add new session
user.sessions = Array.isArray(user.sessions)
? [...removeExpiredSessions(user.sessions), session]
: [session]
}
// Prepare updated Google authentication data
const googleUpdate = {
accessToken: tokens.access_token,
authProvider: provider,
idToken: tokens.id_token,
providerUserId: sub,
refreshToken: tokens.refresh_token,
tokenExpiry: tokens.expiry_date ? new Date(tokens.expiry_date).toISOString() : undefined,
tokenIssuedAt: now.toISOString(),
tokenType: tokens.token_type ?? 'Bearer',
}
// Merge with existing auth data to preserve other fields
const googleAuth = mergeAuth(existing, googleUpdate)
const authStrategies = [
...existingStrategies.filter((s) => s?.authProvider !== provider),
googleAuth,
]
// Persist updated authentication and session data
await payload.db.updateOne({
collection: 'users',
data: {
externalId: {
...(user.externalId || {}),
authStrategies,
},
sessions: user.sessions,
},
id: user.id,
returning: false,
})
// Attach session metadata to user object
const sessionUser: SessionUser = {
...user,
_sid: sid,
_strategy: provider,
collection: 'users',
}
return { user: sessionUser }
} catch (error) {
console.error('Google authentication error:', error)
return { user: null }
}
},
name: 'google',
}
export const removeExpiredSessions = (
sessions: {
createdAt?: null | string
expiresAt: string
id: string
}[],
) => {
const now = new Date()
return sessions.filter(({ expiresAt }) => new Date(expiresAt) > now)
}/utils/mergeAuth.ts
export function mergeAuth<T extends object>(existing: T | undefined, updates: Partial<T>): T {
const base = (existing ?? {}) as T
const pick = <K extends keyof T>(key: K): NonNullable<T[K]> | null => {
const updateVal = updates[key]
if (updateVal === undefined || updateVal === '' || updateVal === null) {
return base[key] ?? null
}
return updateVal
}
const result = {} as T
for (const key of Object.keys({ ...base, ...updates }) as (keyof T)[]) {
// @ts-expect-error We intentionally allow null for missing values
result[key] = pick(key)
}
return result
}Trigger
The client application does not interact with OAuth providers directly.
Its sole responsibility is to initiate the authorisation endpoint and redirect the browser using the URL returned by the server. This ensures that PKCE values, provider credentials, and OAuth state remain entirely server-controlled.
This pattern allows additional providers to be introduced without altering the frontend logic and avoids duplicating authentication behavior across clients.
'use client'
import { useState } from 'react'
import { FcGoogle } from 'react-icons/fc'
import { Button } from '@/components/ui/button'
import { type User } from '@/payload/payload-types'
type Provider = NonNullable<
NonNullable<User['externalId']>['authStrategies']
>[number]['authProvider']
export const AfterLogin = () => {
const [loading, setLoading] = useState(false)
// OAuth is initiated via a top-level browser redirect
const handleLogin = (provider: Provider) => () => {
setLoading(true)
window.location.href = `/api/users/auth/${provider}`
}
return (
<div className="twp">
<div className="my-4 flex justify-center gap-2">
<Button
disabled={loading}
onClick={handleLogin('google')}
className="flex cursor-pointer items-center gap-2 rounded-4xl border-border p-6 text-base font-semibold text-foreground shadow transition-all duration-300 hover:shadow-md"
size="icon"
variant="outline"
>
<FcGoogle className="size-6" />
</Button>
</div>
</div>
)
}Frontend
In the admin panel, OAuth is initiated by a minimal trigger component that always returns users to the Payload admin interface after login. On the public frontend, the SocialLogin component calls the same authorisation endpoint but includes a client_login=true flag, allowing the callback to route authenticated users back into the client-facing application instead of the admin UI.
@/component/ui/social-login.tsx
'use client'
import { useState } from 'react'
import { FcGoogle } from 'react-icons/fc'
import { Button } from '@/components/ui/button'
import { type User } from '@/payload/payload-types'
type Provider = NonNullable<
NonNullable<User['externalId']>['authStrategies']
>[number]['authProvider']
export const AfterLogin = () => {
const [loading, setLoading] = useState(false)
const handleLogin = (provider: Provider) => () => {
setLoading(true)
window.location.href = `/api/users/auth/${provider}`
}
return (
<div className="twp">
<div className="my-4 flex justify-center gap-3">
<Button
disabled={loading}
onClick={handleLogin('google')}
className="cursor-pointer rounded-4xl border-border p-7 text-base font-semibold text-foreground shadow transition-all duration-300 hover:shadow-md"
size="icon"
variant="outline"
>
<FcGoogle className="size-6" />
</Button>
</div>
</div>
)
}Collection
Before any OAuth flow can be initiated, the Payload user collection must be configured to accept external authentication strategies and issue sessions programmatically.
Payload does not infer authentication behaviour at runtime. The collection must explicitly declare that it supports authentication, how tokens are generated, and which external strategies are permitted. Without this configuration, OAuth callbacks may resolve identity but will fail to produce a valid session.
At a minimum, the user collection must enable authentication and expose its API endpoints. Token expiration and cookie behaviour should be defined here to ensure consistency across native and external login methods.
A typical configuration includes:
- Enabling
authon the collection - Defining token expiration and cookie scope
- Allowing API access for programmatic authentication
- Declaring supported authentication strategies
This configuration establishes the contract that all subsequent OAuth layers rely on.
import { type CollectionConfig } from 'payload'
import { googleAuth } from '@/payload/collections/users/api/auth/google/auth'
import { googleCallback } from '@/payload/collections/users/api/auth/google/callback'
import { googleStrategy } from '@/payload/collections/users/strategies/google'
export const Users: CollectionConfig = {
// ...
slug: 'users',
auth: {
cookies: {
domain: process.env.NEXT_PUBLIC_SERVER_HOSTNAME!,
sameSite: 'Lax',
secure: process.env.NODE_ENV === 'production',
},
loginWithUsername: {
allowEmailLogin: true,
requireEmail: true,
requireUsername: true,
},
maxLoginAttempts: 5,
strategies: [googleStrategy],
tokenExpiration: 60 * 60 * 24 * 7,
useSessions: true,
},
endpoints: [googleAuth, googleCallback],
fields: [
{
tabs: [
{
fields: [
{
fields: [
{
admin: {
hidden: true,
readOnly: true,
},
name: 'providerUserId',
type: 'text',
},
{
admin: {
hidden: true,
readOnly: true,
},
name: 'accessToken',
type: 'text',
},
{
admin: {
hidden: true,
readOnly: true,
},
name: 'refreshToken',
type: 'text',
},
{
admin: {
hidden: true,
readOnly: true,
},
name: 'tokenType',
type: 'text',
},
{
admin: {
hidden: true,
readOnly: true,
},
name: 'idToken',
type: 'text',
},
{
admin: {
hidden: true,
readOnly: true,
},
name: 'tokenExpiry',
type: 'date',
},
{
admin: {
hidden: true,
readOnly: true,
},
name: 'refreshTokenExpiry',
type: 'date',
},
{
admin: {
hidden: true,
readOnly: true,
},
name: 'tokenIssuedAt',
type: 'date',
},
{
admin: {
hidden: true,
readOnly: true,
},
name: 'authProvider',
options: [
{
label: 'Google',
value: 'google',
},
],
type: 'select',
},
],
label: 'Strategies',
name: 'authStrategies',
type: 'array',
},
],
label: 'External ID',
name: 'externalId',
},
],
type: 'tabs',
},
],
// ...
}A custom OAuth strategy in Payload is a coordinated system rather than a single integration point. Implementing components out of sequence often results in incomplete state handling and difficult-to-diagnose failures.
The recommended implementation order is:
- PKCE utilities
- Cookies utilities
- Authorization endpoint
- Callback endpoint
- Payload authentication strategy
- Frontend trigger
- Collection
Following this order ensures that cryptographic state, OAuth validation, and Payload session issuance remain consistent throughout the flow.
If this has helped you at all, help us out by leaving us a review: https://g.page/r/CcUcaY_5f4XrEBM/review
If you do need help or have questions feel free to leave a comment.
OAuth authentication in Payload is best approached as a controlled identity handoff rather than a direct provider integration. By isolating responsibilities across clearly defined layers, teams can introduce external identity providers while preserving Payload’s authority over authentication, sessions, and permissions.
This structure scales cleanly to additional providers and supports predictable, auditable authentication behavior across environments.
Author
Vincent VuVincent is the founder and director of Rubix Studios, with over 20 years of experience in branding, marketing, film, photography, and web development. He is a certified partner with industry leaders including Google, Microsoft, AWS, and HubSpot. Vincent also serves as a member of the Maribyrnong City Council Business and Innovation Board and is undertaking an Executive MBA at RMIT University.
