MCP plugin: native OAuth 2.1 authorization server (for claude.ai web custom connectors) #16745
jhb-dev
started this conversation in
Feature Requests & Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Plugin:
@payloadcms/plugin-mcpSummary
@payloadcms/plugin-mcpauthenticates MCP requests only via a static API-key bearer (thepayload-mcp-api-keyscollection). That works for Claude Code, where you paste the key into the MCP config. But claude.ai's custom-connector dialog (web, not Claude Code) only speaks OAuth 2.1 — it performs dynamic client registration, an authorization-code + PKCE flow, and uses bearer access tokens. There is nowhere to paste a static key.As a result, to use a Payload MCP server from claude.ai today you must hand-build a full OAuth 2.1 authorization server alongside the plugin. Since the plugin already owns the
/api/mcpresource and Payload already has a user/auth system, it is the natural place to provide a first-party OAuth 2.1 authorization server, gated behind a config flag.Current behavior
payload-mcp-api-keys).overrideAuth(req, getDefaultMcpAccessSettings)hook.What's possible today (and why it's not enough)
The plugin's
overrideAuth(req, getDefaultMcpAccessSettings)hook is the only thing that makes external OAuth possible. We use it to resolve the bearer ourselves:oauth-tokenscollection.mcpscope, load the real Payload user, return{ user, ...accessFlags }.getDefaultMcpAccessSettings()and let the plugin's static API-key path handle it.That fallback is what lets OAuth (claude.ai) and static keys (Claude Code) coexist on the same
/api/mcpendpoint. But this hook is the only thing the plugin contributes — everything below has to be built from scratch.What you have to build yourself today (~1,000 LOC per app)
We successfully bolted OAuth 2.1 onto the plugin in our own projects, but it required duplicating roughly a thousand lines per app:
3 collections
oauth-clients— dynamically registered clientsoauth-codes— single-use authorization codes, ~60s TTLoauth-tokens— access (1h) / refresh (30d) tokens, stored hashedDiscovery metadata
/.well-known/oauth-authorization-server(RFC 8414)/.well-known/oauth-protected-resource(RFC 9728)/api/mcp-suffixed variants that MCP clients probe. App Router doesn't route dot-prefixed segments, so these need Next.js rewrites.Endpoints
/oauth/register— RFC 7591 dynamic client registration (validates redirect URIs: HTTPS only except loopback per RFC 8252; supports public PKCE clients and confidentialclient_secret_post)./oauth/authorize— a consent screen that reuses the existing Payload admin session (payload.auth()on forwarded headers) rather than re-prompting for credentials; redirects to/admin/loginif not signed in; CSRF-protected with an HMAC consent token bound to the user id./oauth/authorize/decision— issues the authorization code./oauth/token— authorization-code (with PKCE S256 verification) and refresh-token grants; codes are atomically claimed via a conditional update to defeat race/replay.Security details a built-in implementation would standardize
These are easy to get wrong and worth providing once, correctly:
CMS_URL), never derived from request headers in production (Host/X-Forwarded-Hostspoofing would point discovery metadata at attacker endpoints).X-Frame-Options: DENY+frame-ancestors 'none'to prevent clickjacking of the authorize action.The ask
Provide a first-party OAuth 2.1 authorization server in
@payloadcms/plugin-mcp, gated behind a config flag:/api/mcpendpoint.The
overrideAuthhook already proves this can be grafted on externally — but every Payload user who wants claude.ai support has to reimplement the same ~1k lines and get the same non-obvious security details right.Beta Was this translation helpful? Give feedback.
All reactions