A client asked me to secure their MCP server. Simple enough β throw in some API keys, right?
But the Model Context Protocol spec had other ideas: OAuth2 with Dynamic Client Registration, Resource Indicators (RFC 8707), Protected Resource Metadata (RFC 9728)...
One week and dozens of authentication bugs later, I have an MCP server that works with Claude and ChatGPT. Here's everything I learned β so you don't have to repeat the dance.
Why Not Just Use API Keys?
You're building an MCP server β an API that lets AI assistants like Claude and ChatGPT call external tools. Your first instinct: add a simple Authorization: Bearer sk-... header and call it a day.
Then you read the MCP Authorization spec (2025-11-25 revision) and realize1:
- OAuth 2.1 (draft-ietf-oauth-v2-1) is MUST
- Client ID Metadata Documents (draft-ietf-oauth-client-id-metadata-document) is SHOULD
- Dynamic Client Registration (RFC 7591) is MAY
- Resource Indicators (RFC 8707) is MUST
- Protected Resource Metadata (RFC 9728) is MUST
- Authorization Server Metadata (RFC 8414) is MUST
"This is overkill for my side project," you think.
But here's the reality: major MCP clients already implement these specs β Claude, ChatGPT, LibreChat, and others. Your MCP server either speaks OAuth2 correctly, or it doesn't work with any of them.
Why OAuth2 Matters for MCP Servers
If you're new to OAuth2: think of it as a crowd of services dancing together β one misstep and the whole routine falls apart.

In our setup, the crowd includes:
- Your MCP Server (the API you're protecting)
- Ory Network (the bouncer checking credentials)
- GitHub (proving who you are via social login)
- MCP clients like Claude, ChatGPT, or LibreChat (requesting access)
Each dancer has a role, and OAuth2 is the choreography that makes them work together without your MCP server ever seeing passwords.
Why go through this trouble instead of simple API keys? OAuth2 solves real security problems:
User Context
MCP servers need to know who is making requests. Without proper auth:
- All users share the same data (no privacy)
- No audit trail (who did what?)
- Can't implement rate limiting per user
- Resource ownership is impossible
Token Lifecycle
OAuth2 access tokens expire by design. API keys can have expiration dates too, but in practice most don't β and even when they do, rotation is manual and easy to forget. OAuth2 bakes this in:
- Short-lived access tokens limit the blast radius of a compromise
- Refresh tokens enable seamless rotation without user disruption
- Revocation is immediate (especially with opaque tokens + introspection)
Scope Limitation
OAuth2 tokens carry scopes (permissions):
-
openid= identity information -
offline_access= refresh tokens - Custom scopes = fine-grained access control
A single API key is all-or-nothing. OAuth2 tokens can be scoped to specific operations.
Standard Protocol
MCP clients already implement OAuth2. If you use API keys:
- You need a custom authentication flow
- MCP clients won't auto-discover your server
- You're maintaining non-standard auth code
Security by simplicity: Using OAuth2 means following a proven standard instead of rolling your own authentication.
Okay, I'm lying β OAuth2 is complicated.
Learning by Doing
To make sense of all this, let's build a minimal MCP server secured with OAuth2 using Ory Hydra and Kratos. By the end, you'll have:
- β OAuth2 authorization and consent (Hydra)
- β Social login via GitHub (Kratos)
- β οΈ Works with Claude Code CLI (with caveats - see Bug #2)
- β Works with Claude.ai Custom Connectors
- β Works with Claude Desktop
- β Works with ChatGPT Web App (tested - actually works better than Claude!)
- β Codex VSCode extension (OAuth not supported for custom servers)
- β User-scoped resource isolation (you only see your data)
The Stack: What Each Piece Does
Why this stack?
- Ory Kratos and Hydra: Identity management and OAuth2 server trusted by companies like OpenAI for scale and security
- Fastify MCP: Fastify plugin, forked from @platformatic/mcp with better OAuth2 support
Part 1: Setting Up Ory Network
I use Ory Network for this guide. It hosts your Hydra and Kratos instances so you don't have to manage infrastructure.
It's the same team behind Ory Hydra, Kratos, Keto, and Oathkeeper.
Yes, they have a free Developer tier.
Step 1: Create Your Ory Network Project
- Register account: Visit https://console.ory.sh and sign up
- Create workspace: Click "New Workspace" (organizational container for projects)
-
Create project: Within your workspace, click "New Project"
- Name: "MCP Server Development"
- Region: Choose closest to you
-
Generate API key: Go to Developers tab β API Keys β "Create API Key"
- Save the key securely (you'll need it for introspection auth)
I can feel your sarcasm: "We use an API key to avoid API keys?" Yes, but this key is for Ory Network management only, not for your MCP server clients.
Note your project details:
- Workspace ID: (in workspace settings)
- Project ID: (in project settings)
- Project slug:
{your-slug}.projects.oryapis.com
Detailed setup guide: See Deployment to Ory Network for complete walkthrough including CLI setup.
Prefer running everything locally? Skip Ory Network and use Docker Compose. Your configuration is fully portable to self-hosted Hydra/Kratos later β I tested both, and the same JSON config works on either. This is the beauty of Ory.
This compose file is from an older project but demonstrates the full Ory stack (Hydra, Kratos, PostgreSQL, Self-Service UI).
Understanding OAuth2 Client Registration for MCP
Before configuring, let's understand why MCP needs Dynamic Client Registration (DCR). (The spec looks over-engineered β until you realize it solves real problems.)
"I just want to call an API, why do I need to read so many RFCs?"
The Problem: Unknown Clients
Traditional OAuth2 assumes you know your clients upfront:
- Developer manually registers app in OAuth console
- Gets hardcoded
client_idandclient_secret - Ships these credentials in the app
But with MCP: When someone discovers your server URL, their Claude client has never heard of your server. How do they get credentials? You can't expect every user to register manually in your OAuth console β that defeats the whole "seamless AI integration" promise.
Solution 1: Dynamic Client Registration (DCR - RFC 7591)
DCR lets clients register themselves at runtime:
- Client discovers MCP server
- Client reads
registration_endpointfrom OIDC discovery - Client POSTs metadata to register:
{redirect_uris, grant_types, scopes} - Server returns new
client_id(and maybeclient_secret) - Client proceeds with normal OAuth flow using the registered scopes
Solution 2: Client ID Metadata Documents (CIMD)
The November 2025 MCP spec introduced CIMD as the SHOULD (recommended) approach, with DCR as MAY (optional fallback).
How it works: Clients publish metadata at an HTTPS URL they control. That URL becomes the client_id.
Example metadata at https://app.example.com/oauth/client-metadata.json:
{
"client_id": "https://app.example.com/oauth/client-metadata.json",
"client_name": "My MCP Client",
"grant_types": ["authorization_code"],
"redirect_uris": ["http://localhost:3000/callback"],
"token_endpoint_auth_method": "none"
}
During OAuth flow:
- Client sends
client_id=https://app.example.com/oauth/client-metadata.json - Authorization server fetches that URL
- Server validates:
client_idmatches URL,redirect_uriis in allowed list - Server caches metadata (respecting HTTP cache headers)
Comparison:
| Aspect | DCR | CIMD |
|---|---|---|
| MCP Spec Status | MAY (optional) | SHOULD (recommended) |
| Client ID | Server-minted UUID | Client's HTTPS URL (e.g., /client.json) |
| Registration | POST to server | Self-published JSON document |
| Server state | Stores records in database | Fetches on-demand, caches via HTTP headers |
| Best for | Local agents without reliable URLs | Web/hosted clients with stable domains |
| Localhost support | β Works fine | β οΈ Risk: anyone can claim localhost URIs |
For this guide: I use DCR because:
- Local agents (Claude CLI, CLI tools) don't have stable HTTPS URLs to host metadata
- Ory Hydra supports DCR out of the box
- DCR works for both localhost and production scenarios
CIMD note: Authorization servers advertise CIMD support via client_id_metadata_document_supported: true in their metadata. Ory Hydra doesn't support this yet, but if you're building a web-based MCP client with a stable domain, CIMD is the preferred approach per the MCP spec.
Ory Configuration Reference
Here are the key configurations you need. In Ory Network, the project config is a single JSON document that merges settings from all Ory services:
-
services.oauth2β Ory Hydra (OAuth2/OIDC server) -
services.identityβ Ory Kratos (Identity management, social login) -
services.permissionβ Ory Keto (Authorization, not used in this guide)
There are two ways to apply these configs:
- Import JSON via CLI (fastest - paste the whole merged config)
- Configure via Console UI (guided, click-through for each service)
OAuth2 Configuration (Hydra)
{
"services": {
"oauth2": {
"config": {
"oauth2": {
"pkce": {
"enforced": false,
"enforced_for_public_clients": true
}
},
"oidc": {
"dynamic_client_registration": {
"default_scope": ["openid", "offline_access"],
"enabled": true
}
},
"strategies": {
"access_token": "opaque",
"scope": "wildcard"
},
"ttl": {
"access_token": "1h0m0s",
"refresh_token": "720h0m0s"
},
"webfinger": {
"oidc_discovery": {
"auth_url": "https://{slug}.projects.oryapis.com/oauth2/auth",
"client_registration_url": "https://{slug}.projects.oryapis.com/oauth2/register",
"jwks_url": "https://{slug}.projects.oryapis.com/.well-known/jwks.json",
"token_url": "https://{slug}.projects.oryapis.com/oauth2/token",
"userinfo_url": "https://{slug}.projects.oryapis.com/userinfo"
}
}
}
}
}
}
Key settings explained:
-
access_token: "opaque"- Use opaque tokens (revocable, secure) -
dynamic_client_registration.enabled: true- Enables DCR for MCP clients -
pkce.enforced_for_public_clients: true- Enforces PKCE for CLI clients like Claude Code -
webfinger.oidc_discovery.client_registration_url- Can override for DCR proxy (see Bug #1 workaround)
Why opaque tokens?
- Immediate revocation (logout, compromised tokens)
- No JWT parsing vulnerabilities
- Token contents stay server-side
- Ory's introspection is fast and cached
Identity Configuration (Kratos - for GitHub login)
{
"services": {
"identity": {
"config": {
"selfservice": {
"methods": {
"oidc": {
"config": {
"providers": [
{
"client_id": "YOUR_GITHUB_CLIENT_ID",
"client_secret": "YOUR_GITHUB_CLIENT_SECRET",
"id": "github",
"provider": "github",
"scope": ["user:email"]
}
]
},
"enabled": true
}
}
}
}
}
}
}
See "Part 2: GitHub Social Login" below for how to get GitHub OAuth credentials.
Step 2: Apply Configuration
Option A: Import Config via Ory CLI (Fastest)
Save the merged config (OAuth2 + Identity sections) to a file and update your Ory project:
# Save your config to ory-config.json
# (Merge the OAuth2 and Identity configs from above)
# Apply to your Ory project
ory update project <project-id> \
--workspace <workspace-id> \
--file ory-config.json
Hint: You can export your current config with:
ory get project <project-id> \
--workspace <workspace-id> \
--output json > current-ory-config.json
Done! Your Ory project is now MCP-ready.
Prefer clicking through the UI? Each setting can be configured in the Ory Console: 2.1 Enable Dynamic Client Registration: 2.2 Set Token Strategy: 2.3 Enable PKCE: 2.4 Set Token TTLs: 2.5 Enable OIDC for Identity (Social Login):Option B: Configure via Console UI (Guided)
openid, offline_access
1h
720h (30 days)
user:email
Step 3: Create a Static OAuth2 Client
Create a static OAuth2 client for testing and for use with Claude Desktop or Claude.ai. Even if you plan to use DCR in production, having a static client helps you debug the OAuth flow without worrying about DCR-specific issues (like Bug #1).
Via Ory Console:
- Go to OAuth2 & OpenID Connect β OAuth2 Clients
- Click Create Client
- Fill in:
- Name: "Manual Test Client"
- Grant Types: Authorization Code, Refresh Token
- Redirect URIs:
http://localhost:8000/oauth/callback - Scopes:
openid,offline_access
- Save and note the
client_idandclient_secret
Via Ory CLI:
ory create oauth2-client \
--project {your-project-id} \
--name "Manual Test Client" \
--grant-type authorization_code,refresh_token \
--response-type code \
--scope openid,offline_access \
--redirect-uri "http://localhost:8000/oauth/callback"
π‘ Keep these credentials - you'll use them for testing in Part 4.
Part 2: GitHub Social Login (Optional but Recommended)
Fill in: Save Via Ory Console: Or update via JSON import (use the Identity config JSON above with your GitHub credentials).Expand GitHub Social Login setup
Why GitHub?
Step 1: Create GitHub OAuth App
https://{your-slug}.projects.oryapis.com
https://{your-slug}.projects.oryapis.com/self-service/methods/oidc/callback/github
Client ID and Client Secret
Step 2: Add to Kratos Config
user:email
Part 3: Your MCP Server with OAuth2
The MCP server is built using Fastify and a fork of @platformatic/mcp that adds better OAuth2 support. This fork fixes:
OIDC Discovery & OAuth Compliance (PR #97):
- Original: Hardcoded
/oauth/*paths didn't work with Ory Hydra's non-standard endpoints - Fixed: Auto-discovers endpoints via
/.well-known/openid-configuration, addsredirect_urisupport, allows excluding paths from auth (e.g.,/health)
Resource Subscriptions (PR #98):
- Original: No support for MCP resource subscriptions
- Fixed: Adds subscription/unsubscription handlers and query parameter URI matching
DCR Proxy & Token Introspection Auth (PR #100):
- Original:
/oauth/registerendpoint required auth (chicken-egg problem), no way to authenticate introspection requests - Fixed: DCR endpoint skips auth, adds
introspectionAuthconfig for Ory admin API, adds DCR hooks for proxy pattern (needed for Bug #1 workaround)
Full disclosure: This is my fork, tested through building the Baume MCP server. The upstream PRs await review, but you need these fixes now.
Step 1: Install Dependencies
npm install @getlarge/fastify-mcp fastify @sinclair/typebox @fastify/type-provider-typebox
Step 2: Create Your Server
β οΈ Common Pitfall: Schema Definition
Before you write any tools, know this: @getlarge/fastify-mcp (and @platformatic/mcp) require tool input schemas to be objects at the top level. If you need mutually exclusive inputs (e.g., path OR url), wrap the union inside an object property:
// β Won't work - Union at top level
inputSchema: Type.Union([Type.Object({path: ...}), Type.Object({url: ...})])
// β
Works - Union inside object property
inputSchema: Type.Object({
spec: Type.Union([
Type.Object({ path: Type.String() }),
Type.Object({ url: Type.String({ format: 'uri' }) })
])
})
This also improves clarity for AI clients (ChatGPT, Claude) that sometimes struggle with flat union schemas. Thank me later.
File: src/server.ts
import Fastify from 'fastify';
import mcpPlugin from '@getlarge/fastify-mcp';
import { Type } from '@sinclair/typebox';
const fastify = Fastify({ logger: true });
fastify.get('/health', async () => ({ status: 'ok' }));
// Register MCP plugin with OAuth2 config
await fastify.register(mcpPlugin, {
authorization: {
enabled: true,
authorizationServers: ['https://{your-slug}.projects.oryapis.com'],
resourceUri: 'http://localhost:8000',
excludedPaths: ['/health'],
tokenValidation: {
// Using introspection for opaque tokens
introspectionEndpoint:
'https://{your-slug}.projects.oryapis.com/admin/oauth2/introspect',
// For Ory Network, authenticate introspection with API key:
introspectionAuth: {
type: 'bearer',
token: process.env.ORY_API_KEY,
},
validateAudience: false, // See "Bug #4: Empty JWT Audience"
},
},
});
fastify.mcpAddTool(
{
name: 'hello',
description: 'Say hello with user context',
inputSchema: Type.Object({
name: Type.String(),
}),
},
async (input, context) => {
const userId = context.authContext?.userId;
return {
content: [
{
type: 'text',
text: `Hello ${input.name}! Your user ID: ${userId}`,
},
],
};
},
);
await fastify.listen({ port: 8000, host: '0.0.0.0' });
console.log('π MCP Server running on http://localhost:8000');
Step 3: Start Your Server
The Five Bugs I Hit (And Their Fixes)
War stories section β I'm listing these in order of severity, so if you're blocked, start from the top.
- Bug #1 blocks all Claude clients (DCR validation)
- Bug #2 blocks Claude Code CLI specifically (scope parameter)
- Bugs #3-5 are configuration issues you'll hit along the way
Bug #1: All Claude Clients Reject DCR Responses with Empty URI Fields
This was the first wall I hit. Dynamic Client Registration succeeds on Hydra's side β the client gets created, logs look fine. Then every Claude client (Code CLI, Claude.ai, Claude Desktop) crashes with a validation error: client_uri, logo_uri, tos_uri, or contacts must be parseable/valid.
I dug into Hydra's DCR response and found the problem:
{
"client_id": "...",
"client_secret": "...",
"client_uri": "", // Empty string fails Claude's Zod schema
"contacts": null, // Null fails array validation
"logo_uri": "",
"policy_uri": "",
"tos_uri": ""
}
Claude's Zod schema expects these fields to be either valid URLs (for *_uri fields), arrays (for contacts), or omitted entirely β not empty strings or null.
Status: Open issue #13685
This affects all Claude clients, not just Claude Code CLI. That makes it different from Bug #2 (scope parameter), which only hits the CLI.
The frustrating part: you can't configure Ory Hydra to omit these fields. They're part of the DCR spec, and Hydra includes them even when empty. So I built a workaround.
Solution: DCR Proxy
A lightweight proxy between Claude and Hydra that strips the problematic fields before Claude sees them. @getlarge/fastify-mcp supports this as a built-in hook, so the proxy runs in the same process as your MCP server β no separate deployment needed.
// DCR Proxy hook in @getlarge/fastify-mcp
// Registered as client_registration_url in your Ory OIDC discovery config
app.post('/oauth2/register', async (req, res) => {
// Forward DCR request to Hydra
const hydraResponse = await fetch(
'https://{slug}.projects.oryapis.com/oauth2/register',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req.body),
},
);
const client = await hydraResponse.json();
// Remove fields that are empty strings or null
const cleanedClient = Object.fromEntries(
Object.entries(client).filter(([_, value]) => {
// Keep non-empty strings, non-null values
return value !== '' && value !== null && value !== undefined;
}),
);
res.json(cleanedClient);
});
Configure Ory to use the proxy:
In your Ory project config, set the DCR endpoint to your MCP server's proxy endpoint:
{
"webfinger": {
"oidc_discovery": {
"client_registration_url": "https://your-mcp-server.com/oauth2/register"
}
}
}
Note: Ory Network allows customizing the
client_registration_urlin the OIDC discovery document. Set it toundefinedto hide DCR entirely (if you only use static clients).
Why this works: Claude never sees the empty URI fields, so Zod validation passes π«’.
Implementation reference: See the DCR proxy hook in fastify-mcp or its usage in the Baume MCP server
You could also consider using placeholder URLs (e.g.,
https://example.com) instead of omitting fields, but omitting is cleaner and avoids confusion.
Bug #2: Claude Code CLI Missing Scope Parameter
As of January 2026, Claude Code (CLI) has a known bug (since July 2025, tagged oncall) where it doesn't send the scope parameter during OAuth2 authorization, causing the flow to fail entirely. Use Claude.ai or Claude Desktop instead until Anthropic fixes this.
Server-side mitigation attempts (all failed):
- Injecting scopes in DCR response β Claude Code ignores it
- Using WWW-Authenticate scope parameter β Requires initial token (chicken-egg problem)
- Ory default scopes β Only works with client_credentials grant, not authorization_code
How ironic, Anthropic wrote the MCP Authorization spec that requires proper scope handling, but Claude Code CLI doesn't implement it.
Bug #3: Token Validation Configuration Mismatch
I kept getting this error:
The token is malformed. (error code: FAST_JWT_MALFORMED)
It took me some time to realize my MCP server was trying to parse an opaque token as a JWT (using JWKS token validation). Opaque is Hydra's default strategy β and I'd forgotten that when configuring the server.
Understanding Token Validation Options
Hydra supports two token strategies:
| Strategy | Token Format | Validation Method | Trade-offs |
|---|---|---|---|
| opaque (recommended) | Random string (ory_at_...) |
Introspection endpoint | β
Immediate revocation β Better security β Network call per request |
| jwt | JSON Web Token (eyJhbGci...) |
JWKS (signature verification) | β
Fast (JWKS response is cached) β Can't revoke before expiry β Exposed claims |
For this guide and production, I recommend opaque tokens because:
- You can revoke tokens immediately (logout, compromised tokens)
- Ory's introspection is fast
- No JWT parsing vulnerabilities
- Token contents stay server-side
Your MCP server must use the matching validation method:
If using opaque tokens (recommended):
authorization: {
tokenValidation: {
introspectionEndpoint: 'https://{project}.projects.oryapis.com/admin/oauth2/introspect',
// For Ory Network, authenticate with API key:
introspectionAuth: {
type: 'bearer',
token: process.env.ORY_API_KEY
}
// Local Hydra: no auth needed if admin API is on localhost
},
}
If using JWT tokens:
authorization: {
tokenValidation: {
jwksUri: 'https://{project}.projects.oryapis.com/.well-known/jwks.json',
// Validates JWT signature locally - no network call
},
}
Setting Hydra's token strategy:
# For opaque tokens (recommended)
ory patch oauth2-config \
--project <project-id> \
--replace "/strategies/access_token=opaque"
# For JWT tokens (if you prefer local validation)
ory patch oauth2-config \
--project <project-id> \
--replace "/strategies/access_token=jwt"
Bug #4: Empty JWT Audience (RFC 8707 Missing)
Token validation kept failing with this:
Audience validation failed: expected [http://localhost:8000], got []
The MCP spec is clear β clients MUST implement Resource Indicators (RFC 8707). That means sending a resource parameter during authorization:
GET /oauth2/auth?...&resource=http://localhost:8000
This binds the token to a specific MCP server via the JWT aud claim. Except Claude.ai doesn't send it (as of Jan 2026). So the audience comes back empty, and validation fails.
Workaround: Disable audience validation:
tokenValidation: {
validateAudience: false, // Until Claude.ai implements RFC 8707
}
β οΈ Security warning for multi-tenant deployments: Tokens issued by your Ory instance can technically be used against any MCP server using the same issuer. For single-tenant deployments, this is acceptable. For multi-tenant, you must add validation (e.g., check client_id against known registrations, or validate custom claims).
Status: Pending Claude.ai update to support RFC 8707.
Bug #5: Ory Elements Consent Error (False Positive)
This one wasted my time because the real blocker was Bug #3 (token validation). I didn't know that yet. After clicking "Accept" on the consent screen, Ory Elements throws:
Unhandled Promise Rejection: Error: [Ory/Elements]: OAuth2 consent flow not completed.
Except... the flow actually succeeds. Claude receives a valid authorization code, tokens work fine, everything is functional. The UI just shows an error because Ory Elements doesn't handle the redirect properly.
Ory fixed the issue #587, so it should not get in your way in future releases.
Part 4: Testing the Full OAuth2 Flow
Programmatic Testing with Hydra's Admin API
You could test manually: generate a PKCE challenge, open the auth URL in a browser, click through GitHub login, copy the code from the callback, exchange it with curl... but that gets old fast.
A better approach: use Hydra's Admin API to accept login and consent programmatically β no browser, no identities, no clicking. This is what I use in my e2e tests.
The flow looks like this:
-
Start the auth flow β
GET /oauth2/auth?client_id=...&scope=openid+offline_access&... -
Accept login via Admin API β
PUT /admin/oauth2/auth/requests/login/acceptwith a syntheticsubject(no real user needed) -
Accept consent via Admin API β
PUT /admin/oauth2/auth/requests/consent/acceptgranting the requested scopes -
Exchange the code β
POST /oauth2/tokenas usual
Steps 2 and 3 are the trick β Hydra's Admin API lets you skip the browser entirely. You get a real authorization code and real tokens, without any identity provider involved.
Here's the full flow against Ory Network β client creation, PKCE auth code exchange, token introspection, and authenticated MCP calls:
Want the test script? Hit me up β I have a standalone Node.js script that runs this entire flow against Ory Network's Admin API, no browser needed.
If you see your user ID in the response, OAuth2 is working.
What About Token Refresh?
In our Ory Hydra setup, access tokens expire after 1 hour. The MCP spec says the client handles refresh β your server returns 401 on expiry, and the client uses its refresh token to obtain a new access token from Hydra. A draft section formalizes this, but it's not in the released 2025-11-25 spec yet.
In practice, don't count on it working smoothly. The Python SDK has a P0 bug where get_access_token() returns stale tokens after refresh, and ChatGPT enters a reconnect loop instead of refreshing after long idle periods. @getlarge/fastify-mcp handles the server side (proper 401s with WWW-Authenticate headers), but whether each client refreshes correctly is out of your hands.
Security Checklist
Before going live:
Ory Network:
- [ ] Configure CORS properly (don't use
*in production) - [ ] Set token TTLs appropriate for your use case
MCP Server:
- [ ] Enable HTTPS
- [ ] Re-enable audience validation once Claude.ai implements RFC 8707
- [ ] Rate limit the DCR proxy endpoint (anyone can register unlimited clients)
- [ ] Add rate limiting per user
- [ ] Set up request logging and error monitoring
- [ ] Add health checks and uptime monitoring
- [ ] Rotate your Ory API key periodically
What About ChatGPT?
I wasn't planning to test ChatGPT, but after hitting so many bugs with Claude's OAuth2 implementation, I got curious. Turns out ChatGPT Web App handles OAuth2 better than Claude Code.
ChatGPT Web App (Tested 2026-01-29)
Full OAuth2.1 with DCR just works. DCR flow is clean β no phantom scopes, proper scope handling, no false errors on redirect. It even surfaces rich tool metadata (PUBLIC WRITE, OPEN WORLD, DESTRUCTIVE annotations) with per-tool auth support.
To try it yourself: go to ChatGPT web, enable Developer Mode in settings, add your MCP server URL, and the OAuth flow triggers automatically. Complete login + consent via Ory Hydra, and ChatGPT manages token storage and refresh from there β though after long idle periods it can break into a reconnect loop instead of refreshing.
Your server doesn't need any ChatGPT-specific config. It just needs /.well-known/oauth-protected-resource and proper WWW-Authenticate errors on unauthorized requests β same as Claude, and @getlarge/fastify-mcp handles both.
Codex VSCode Extension (Tested 2026-01-29)
No luck here. Codex has a two-tier system: recommended servers (Linear, Notion, Figma) get full OAuth with an "Install and authenticate" button, but custom servers only get a toggle β no OAuth flow.
You can work around it with bearer tokens in ~/.codex/config.toml:
[mcp_servers.your-server]
url = "https://your-server.com/mcp"
bearer_token_env_var = "YOUR_SERVER_TOKEN"
Then manually obtain a token via Hydra and set export YOUR_SERVER_TOKEN="ory_at_...". But the token expires after 1 hour with no auto-refresh, rotation is manual, and there's no multi-user support. Not great.
Comparison
| Aspect | ChatGPT Web App | Claude Code | Codex VSCode |
|---|---|---|---|
| DCR | β Works cleanly | β οΈ Works with bugs | β Not for custom |
| Scope handling | β Respects scopes | β Adds phantom | N/A |
| Redirect flow | β Proper | β οΈ UI error (bug) | N/A |
| Tool metadata | β Rich annotations | β Basic | β Basic |
| Custom servers | β Full OAuth | β Full OAuth | β Bearer only |
| Per-tool auth | β Yes | β All-or-nothing | β All-or-nothing |
For OAuth2 testing today: ChatGPT Web App > Claude.ai/Desktop > Claude Code CLI > Codex VSCode.
This hurts my feelings as a Claude fanboy (paying for Claude Pro Max 20x), but when OpenAI's ChatGPT Web App handles the OAuth2 specs better than Anthropic's own Claude Code β which literally references the MCP spec Anthropic wrote β something's off. The missing scope parameter bug (#4540) has been open since July 2025, tagged
oncall, still unfixed. Meanwhile ChatGPT just... works.Still, I love Claude's tools and ecosystem β when they work!
Resources
Official Documentation
GitHub Repositories
- This guide's code: getlarge/claude-api-care-plugins
- Fastify MCP plugin (OAuth2-ready): getlarge/fastify-mcp
- Ory Hydra: ory/hydra
- Ory Kratos: ory/kratos
RFCs and Standards Referenced
Full list of referenced specifications
Open Issues
Ory Hydra: Ory Elements: Anthropic / Claude: Platformatic MCP:Issues I've encountered or created during this journey
client_uri, logo_uri, tos_uri must be parseable
Acknowledgments
- Isabella Kohout for the article cover
- Ory team for building excellent open-source auth tooling
- Anthropic for the MCP specification (and for eventually fixing the bugs π )
- Platformatic team for the original MCP server implementation for Fastify
- Community who reported issues and tested edge cases
I spent a week debugging OAuth2 flows so you could skip that week. If it worked, pass it on.
Building something with this? Need help securing your MCP server? β getlarge.eu
-
The MCP spec is a protocol revision maintained by the MCP project, not a ratified IETF standard β requirements may change between revisions. OAuth 2.1 itself is still an IETF draft. I reference the 2025-11-25 revision throughout this article.Β β©






Top comments (0)