npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@purposeinplay/payload-oauth-plugin

v0.2.1

Published

OAuth plugin for Payload CMS 3 with Google sign-in, account linking, PKCE, and CSRF protection.

Downloads

440

Readme

@purposeinplay/payload-oauth-plugin

npm version

OAuth plugin for Payload CMS 3 that adds Google sign-in (and other OAuth providers) alongside or instead of email/password login. Supports pre-approved user enforcement, account linking, domain restriction, and built-in admin UI components.

Features

  • Google OAuth sign-in with PKCE (S256) and HMAC-signed CSRF state
  • Login page integration: a "Sign in with Google" button is added to the admin login page automatically
  • Pre-approved users: require users to be created in Payload before they can sign in with Google
  • Account linking: users connect Google from their account settings after initial password login
  • Domain restriction: only allow emails from specific domains (e.g. @wild.io)
  • Admin UI components: <GoogleLoginButton /> and <ConnectedAccounts />
  • Compatible with TOTP 2FA, audit logging, and Payload's access control
  • Extensible provider interface for adding GitHub, Apple, etc.

Requirements

  • Node.js >= 20
  • ESM only — the package ships "type": "module" and cannot be require()d
  • Peer dependencies (your app provides these):

| Peer | Version | |------|---------| | payload | ^3.0.0 | | next | ^15.0.0 \|\| ^16.0.0 | | react | ^18.0.0 \|\| ^19.0.0 | | @payloadcms/ui | ^3.0.0 |

The only runtime dependency is jose ^6 (JWT signing/verification).

Installation

pnpm add @purposeinplay/payload-oauth-plugin
# or
npm install @purposeinplay/payload-oauth-plugin
# or
yarn add @purposeinplay/payload-oauth-plugin

Quick Start

// payload.config.ts
import { buildConfig } from 'payload'
import { oauthPlugin, googleProvider } from '@purposeinplay/payload-oauth-plugin'

export default buildConfig({
  // Required: used to build the OAuth redirect_uri. Without it the
  // redirect_uri is a bare path and Google rejects the request.
  serverURL: process.env.SERVER_URL ?? 'http://localhost:3000',
  // ... your config
  plugins: [
    oauthPlugin({
      providers: [
        googleProvider({
          clientId: process.env.GOOGLE_CLIENT_ID!,
          clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
          prompt: 'select_account',
        }),
      ],
      allowSignUp: false,
      allowedDomains: ['your-company.com'],
    }),
  ],
})

Then regenerate the Payload import map so the auto-registered login button resolves:

pnpm payload generate:importmap

Setup notes

  • serverURL is required. Set Payload's serverURL (or the plugin's serverURL option). If neither is set, the plugin falls back to '' and the OAuth redirect_uri becomes a bare path, which Google rejects.
  • The auth collection must exist. The plugin throws InvalidConfiguration at startup if the authCollection slug (default 'users') is not found in config.collections.
  • PAYLOAD_SECRET matters. Payload's secret is used for both HMAC state signing and JWT signing — keep it stable across instances.
  • The plugin reads no environment variables itself except NODE_ENV (to set the Secure cookie flag in production). Provider credentials are passed explicitly to googleProvider().
  • Setting enabled: false returns your Payload config completely untouched.
  • Plugin options are exposed to other plugins/code at config.custom.oauth.

What the Plugin Changes

Applying the plugin modifies your Payload config:

  • Schema: adds a hidden oauth group field to the auth collection, containing an accounts array with provider (text, indexed), sub (text, indexed, never readable via API), and email (email) fields. On SQL adapters this is a schema change that may require a migration.
  • Endpoints: registers authorize, callback, and unlink endpoints per provider on the auth collection (see API Endpoints).
  • Auth strategies: registers a named auth strategy oauth-{provider} per provider. Actual session verification is handled by Payload's default JWT strategy, since the plugin issues JWTs with the same shape.
  • Login page: appends '@purposeinplay/payload-oauth-plugin/client#GoogleLoginButton' to admin.components.afterLogin, so a "Sign in with Google" button appears below the admin login form automatically. Run payload generate:importmap after installing. disableLocalStrategy: true sets auth.disableLocalStrategy on the collection.

Configuration Reference

Plugin Options

| Option | Type | Default | Description | |--------|------|---------|-------------| | providers | OAuthProviderConfig[] | required | OAuth providers to enable | | allowSignUp | boolean | true | Auto-create users on first OAuth login. Set false for pre-approved only | | allowedDomains | string[] | omit/[] = any | Restrict sign-in to specific email domains (case-insensitive) | | defaultAttributes | Record<string, unknown> | undefined | Default fields for auto-created users (e.g. { roles: ['editor'] }). Mapped profile fields override it | | accountLinking | boolean | true | Link OAuth accounts to existing users with the same email on login | | disableLocalStrategy | boolean | false | Disable email/password login entirely (also blocks unlinking — see Disconnect Flow) | | authCollection | string | 'users' | Slug of the Payload auth collection | | successRedirect | string \| (req, user) => string \| Promise<string> | '/admin' | Redirect after successful login | | failureRedirect | string \| (req, error) => string \| Promise<string> | '/admin/login' | Redirect after failed login (?error= code appended) | | serverURL | string | Payload serverURL, else '' | Override the server URL used for callback URIs | | enabled | boolean | true | Kill switch; false returns the config untouched |

Google Provider Options

| Option | Type | Default | Description | |--------|------|---------|-------------| | clientId | string | required | Google OAuth client ID | | clientSecret | string | required | Google OAuth client secret | | prompt | 'none' \| 'consent' \| 'select_account' \| 'select_account consent' | undefined | Google account selection / consent behavior | | accessType | 'online' \| 'offline' | undefined | 'offline' requests a refresh token (Google issues it on the first consent only) | | scopes | string[] | [] | Additional scopes beyond the base openid, email, profile | | mapProfileToUser | (profile: OAuthUserInfo) => Record<string, unknown> | undefined | Map Google profile fields to Payload user fields (output is sanitized) | | overrideUserInfoOnSignIn | boolean | false | Update user fields from Google on every login |

Exports

The package ships four entry points (see package.json exports):

| Import path | Exports | Use for | |-------------|---------|---------| | @purposeinplay/payload-oauth-plugin | oauthPlugin, googleProvider, types GoogleProviderOptions, OAuthAction, OAuthErrorCode, OAuthPluginOptions, OAuthProviderConfig, OAuthUserInfo | Server-side config (payload.config.ts) | | @purposeinplay/payload-oauth-plugin/client | ConnectedAccounts, GoogleLoginButton | Client components; this is the path the plugin auto-registers in the import map | | @purposeinplay/payload-oauth-plugin/providers | googleProvider, types GoogleProviderOptions, OAuthProviderConfig, OAuthUserInfo | Importing providers without the plugin entry | | @purposeinplay/payload-oauth-plugin/admin | ConnectedAccounts, GoogleLoginButton (same components as /client) | Admin custom views |

Admin UI Components

Both components are 'use client' React components.

<GoogleLoginButton />

Rendered on the admin login page automatically (the plugin registers it in admin.components.afterLogin) — you normally don't mount it yourself. It renders an "or" divider plus a Google-branded button that navigates to ${apiBase}/oauth/google.

| Prop | Type | Default | |------|------|---------| | apiBase | string | '/api/users' | | label | string | 'Sign in with Google' |

<ConnectedAccounts />

Connect/disconnect panel for account settings. Fetches the current user from /api/users/me, starts linking via ${apiBase}/oauth/{provider}?action=link, and disconnects via POST ${apiBase}/oauth/{provider}/unlink. Built-in labels for google, github, apple.

| Prop | Type | Default | |------|------|---------| | apiBase | string | '/api/users' | | providers | string[] | ['google'] |

import { ConnectedAccounts } from '@purposeinplay/payload-oauth-plugin/admin'

export function SecurityView() {
  return <ConnectedAccounts providers={['google']} />
}

Google Cloud Setup

  1. Go to Google Cloud Console > Credentials
  2. Create an OAuth 2.0 Client ID (Web application)
  3. Add authorized redirect URIs:
    • Development: http://localhost:3000/api/users/oauth/google/callback (adjust port to match your dev server)
    • Production: https://your-domain.com/api/users/oauth/google/callback
  4. Copy the Client ID and Client Secret to your .env (the names below are a convention — the plugin doesn't read them itself; you pass them to googleProvider()):
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-your-client-secret

Use Cases

Pre-approved users (admin creates first)

oauthPlugin({
  providers: [googleProvider({ clientId: '...', clientSecret: '...' })],
  allowSignUp: false,          // user must exist in Payload
  allowedDomains: ['wild.io'], // only company emails
})

Flow: Admin creates user with email + password + role. User logs in with password, then connects Google from settings. Future logins work with either method.

Auto-create from allowed domain

oauthPlugin({
  providers: [googleProvider({ clientId: '...', clientSecret: '...' })],
  allowSignUp: true,
  allowedDomains: ['wild.io'],
  defaultAttributes: { roles: ['editor'] },
})

Google-only (no password)

oauthPlugin({
  disableLocalStrategy: true,
  providers: [googleProvider({ clientId: '...', clientSecret: '...' })],
  allowedDomains: ['wild.io'],
})

Note: with disableLocalStrategy: true, the unlink endpoint always rejects with 400 — disconnecting the only login method would lock users out. Google-only setups cannot disconnect accounts.

Account Linking

Users can connect and disconnect their Google account from the admin panel.

Connect Flow

  1. User is logged in (email/password)
  2. User navigates to security settings
  3. Clicks "Connect Google"
  4. Redirected to Google, signs in
  5. Google account is linked to their Payload user (the Google email must match the session user's email, case-insensitive)

Disconnect Flow

  1. User clicks "Disconnect" in security settings
  2. Plugin verifies the user has a password set (prevents lockout)
  3. Google link is removed

Unlinking is rejected with 400 when:

  • disableLocalStrategy: true (password login disabled — unlinking would lock the user out)
  • the user has no password hash set
  • the provider is not linked

API Endpoints

The plugin registers these endpoints on the auth collection (paths shown for the default users collection):

| Endpoint | Method | Auth | Description | |----------|--------|------|-------------| | /api/users/oauth/google | GET | No | Start OAuth login flow | | /api/users/oauth/google?action=link | GET | Yes | Start account linking flow (redirects to /admin/login without a session) | | /api/users/oauth/google/callback | GET | -- | Handle OAuth callback | | /api/users/oauth/google/unlink | POST | Yes | Remove Google link |

Unlink responses

| Status | Body | When | |--------|------|------| | 200 | { success: true, message: 'google account unlinked' } | Unlinked successfully | | 400 | { error: '...' } | Local strategy disabled / no password set / provider not linked | | 401 | { error: '...' } | Missing or invalid session token | | 404 | { error: 'User not found' } | Session user no longer exists |

Error Codes

Failed logins redirect to failureRedirect with an ?error= query parameter:

| Code | Description | |------|-------------| | state_invalid | CSRF state validation failed | | domain_not_allowed | Email domain not in allowedDomains | | user_not_found | No matching user and allowSignUp is false | | linking_failed | Account linking failed (email mismatch, no session) | | provider_error | OAuth provider returned an error | | unknown | Unexpected error |

Security

OAuth Flow

  • HMAC-SHA256 signed state with nonce, timestamp, and action for CSRF protection
  • PKCE (S256) on every authorization flow
  • Timing-safe comparison on all signature checks
  • State cookies are HttpOnly, SameSite=Lax, Secure in production, scoped to /api, 10-minute max age
  • State and PKCE cookies are cleared on every callback response — success and all error paths — so a state value cannot be replayed

Account Linking

  • Linking requires an active authenticated session
  • Google email must match the authenticated user's email (case-insensitive)
  • The action flag (login/link) is inside the HMAC-signed state (cannot be tampered)
  • No new session token is issued during linking (prevents session fixation)
  • Unlinking is blocked if it would lock the user out (no password set, or local strategy disabled)

Data Protection

  • OAuth sub field is never exposed via the API (read: () => false)
  • The oauth.accounts array field is readable by admins (users whose roles include 'admin') and by any authenticated user at the field level — Payload field access only supports booleans, so who can read the user document at all is controlled by your collection-level access, not by this plugin. The plugin does not enforce self-only visibility
  • mapProfileToUser output is sanitized: roles, email, password, hash, salt, totp, oauth, id, _verified, loginAttempts, lockUntil, and collection are stripped
  • All emails normalized to lowercase before comparison and storage

Redirect Security

  • All redirect URLs validated as same-origin or relative paths
  • No open redirect possible through successRedirect or failureRedirect

Compatibility

  • TOTP 2FA (@purposeinplay/payload-totp): OAuth login produces the same JWT shape, so TOTP middleware enforcement keeps working.
  • Audit Logging (@purposeinplay/payload-audit-log): the callback fires Payload's beforeLogin and afterLogin collection hooks, so login tracking works automatically.
  • Access Control: your collection-level access functions apply identically to OAuth and password users — sessions are standard Payload JWTs.

Adding Custom Providers

Implement the OAuthProviderConfig interface:

import type { OAuthProviderConfig } from '@purposeinplay/payload-oauth-plugin'

export function githubProvider(options: {
  clientId: string
  clientSecret: string
}): OAuthProviderConfig {
  return {
    name: 'github',
    clientId: options.clientId,
    clientSecret: options.clientSecret,
    authorizationUrl: 'https://github.com/login/oauth/authorize',
    tokenUrl: 'https://github.com/login/oauth/access_token',
    scopes: ['read:user', 'user:email'],
    async getUserInfo(accessToken) {
      const res = await fetch('https://api.github.com/user', {
        headers: { Authorization: `Bearer ${accessToken}` },
      })
      const data = await res.json()
      return { email: data.email, sub: String(data.id), name: data.name, picture: data.avatar_url }
    },
  }
}

License

MIT — see LICENSE.

Part of the purposeinplay/payload-plugins monorepo. See the CHANGELOG for release notes, and report issues on the issue tracker.