Build & Tech

Connecting Payload CMS to the Claude desktop and web apps with OAuth

Author
Ric Wood
Published

If you've added the official @payloadcms/plugin-mcp to a Payload project, you've probably noticed it works beautifully with Claude Code and not at all with the Claude desktop or web apps. The plugin is solid. The problem is how those two clients expect to connect.

This is the post we wish we'd found when we hit that wall. It explains why the gap exists, and it points at the small plugin we wrote to close it.

The actual problem

The official MCP plugin authenticates with an API key. You generate a key, you hand it to the client, the client sends it on every request. Claude Code is happy with that, because you configure it locally and you control the file the key sits in.

The Claude desktop and web apps don't work that way. When you add a custom connector there, the app expects to log in to your server: discover where the auth lives, register itself, send you off to approve the connection, and come back holding a token. That's OAuth, and the API-key plugin doesn't speak it. So the connector either refuses to set up or sits there unauthenticated.

There's no flag to flip. The two clients want genuinely different things, and the official plugin only ships one of them.

What we wanted

The same Payload server we already query from Claude Code, available as a proper custom connector in the desktop and web apps, with a real login flow rather than a key copied into a settings box. Crucially, without breaking the API-key path that was already working.

The plugin

So we built the missing half and published it: @brainwebuk/payload-plugin-mcp-oauth.

It's purely additive. It wraps the MCP endpoint handler and adds the OAuth machinery the desktop and web apps need, while leaving the original API-key route completely untouched. Anything already connected to your server keeps working exactly as it did. The handler checks the bearer token: a pmoauth_ token takes the OAuth path, anything else falls through to the original API-key handler unchanged.

What it adds: OAuth 2.1 authorization-code flow with PKCE (S256 only); Dynamic Client Registration (RFC 7591) so Claude.ai registers itself with no manual client setup at your end; discovery documents (RFC 8414 and RFC 9728) at the .well-known paths so the client finds the auth server on its own; tokens hashed at rest with HMAC-SHA-256, with refresh and revocation; and admin views for the tokens you've issued and the clients that have registered.

Before you start

The plugin needs Payload 3, @payloadcms/plugin-mcp 3 (tested against 3.85.0), and Node 20 or newer. The proxy/middleware export supports Next.js 14, 15, and 16. If you're on the official MCP plugin already, you're almost certainly fine.

How it goes together

Six steps, none of them long, but two have a sharp edge worth flagging.

1. Install the package.

pnpm add @brainwebuk/payload-plugin-mcp-oauth

2. Register it after mcpPlugin(), with the same options object.

Two things matter here, and both will catch you out if you miss them. First, order: the plugin throws on boot if it's registered before mcpPlugin(). Second, and this is the one that's genuinely hard to spot: pass the exact same options object to both plugins. The plugin installs its token-validation hook by mutating that object, so a fresh object or a spread copy means OAuth tokens silently fail to authenticate. Because the API-key path keeps working, it's easy to miss. Assign the options to one const and reuse that reference in both calls.

1import { mcpPlugin } from '@payloadcms/plugin-mcp'
2import type { MCPPluginConfig } from '@payloadcms/plugin-mcp'
3import { payloadMcpOAuth } from '@brainwebuk/payload-plugin-mcp-oauth'
4import { buildConfig } from 'payload'
5
6// Assign ONCE and reuse the same reference in both calls
7const mcpOptions: MCPPluginConfig = {
8 collections: {
9 users: { enabled: { find: true, update: true } },
10 media: { enabled: { find: true, create: true } },
11 },
12}
13
14export default buildConfig({
15 plugins: [
16 mcpPlugin(mcpOptions),
17 payloadMcpOAuth({
18 issuer: process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000',
19 mcpPluginOptions: mcpOptions, // the SAME object, not a copy
20 }),
21 ],
22})
23

3. Wire up the proxy or middleware.

Discovery and bare-host connector URLs need two host-level rewrites that a Payload plugin can't register on its own, so the package ships a ready-made handler. The file you create depends on your Next.js version.

On Next.js 16, create src/proxy.ts (Next 16 renamed the middleware convention to proxy):

1export { mcpOAuthMiddleware as proxy } from '@brainwebuk/payload-plugin-mcp-oauth/middleware'
2
3export const config = {
4 matcher: [
5 '/',
6 '/.well-known/oauth-authorization-server',
7 '/.well-known/oauth-protected-resource',
8 ],
9}
10

On Next.js 14 or 15, create src/middleware.ts with the same body, exporting as middleware instead of proxy.

The catch worth burning into memory: declare the config matcher as a local literal in that file. Don't re-export config from the package. As of Next 16, re-exporting it hard-errors and 500s every route in your app, which is a memorable afternoon. If you already have a middleware of your own, the package exports a createMcpOAuthMiddleware() factory so you can compose the two; the README has the snippet.

4. Set your environment variables.

1# Public HTTPS URL used as the OAuth issuer
2NEXT_PUBLIC_SERVER_URL=https://cms.example.com
3
4# HMAC pepper for hashing tokens at rest — required in production (>= 32 chars)
5# Generate with: openssl rand -hex 32
6PMOAUTH_TOKEN_PEPPER=<64-hex-chars>

In development the plugin falls back to an insecure pepper with a warning; in production it throws on boot if the pepper is missing or under 32 characters, which is the correct place to find out.

5. Regenerate the admin import map (optional).

The plugin no longer registers custom admin components, so this step isn't strictly required. If your app already maintains an importMap.js, running it keeps things tidy:

1pnpm payload generate:importmap

6. Apply the schema change.

The plugin adds four collections: oauth-clients, oauth-auth-codes, oauth-tokens, and oauth-csrf-nonces. How you apply the schema change depends on which workflow your app uses. Don't mix them. In dev with push-sync, just start the app and the new tables are created on next boot; don't run migrate:create against a push-synced database or you'll get "table already exists" errors. In production, run the migration pair:

1pnpm payload migrate:create
2pnpm payload migrate

Then in Claude: Settings, Connectors, Add custom connector, enter your server URL. The app handles discovery, registers itself, runs the handshake, sends you to your Payload admin login and a consent screen, and you're connected.

What we'd tell anyone hitting the same wall

If the only thing standing between you and a working desktop connector is the auth flow, you don't need to fork the official plugin or rebuild the MCP layer. The endpoint is fine. You just need the OAuth half bolted on alongside it, additively, so the key-based path you already trust stays exactly where it is.

That's the whole design philosophy of the thing, and it's the same one that runs through everything the studio builds: solve the actual problem, change as little as possible, and leave the working parts working.

1pnpm add @brainwebuk/payload-plugin-mcp-oauth

The package is on npm at npmjs.com/package/@brainwebuk/payload-plugin-mcp-oauth, the source is on GitHub at github.com/BrainWeb/payload-mcp-oauth under an MIT licence, and there's an INSTALL_FOR_AGENTS.md in the package if you'd rather point a coding agent at it than follow the steps by hand. The README also carries a troubleshooting table mapping each likely symptom (the silent 401, the every-route 500, the missing well-known documents) back to the step that fixes it.