- OAuth for MCP Server: Complete Guide to Protecting Claude
OAuth for MCP Server: Complete Guide to Protecting Claude
Implement OAuth 2.1 with Dynamic Client Registration and PKCE to secure your MCP server for Claude clients

⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
Related Posts:
This is Part 3 of the MCP Server Series. Prerequisites: Part 1: Build a Production MCP Server and Part 2: Write Operations. If you're debugging 406 errors with mcp-handler, check out Custom JSON-RPC Implementation for an alternative approach.
After building an MCP server for my blog and expanding it with content publishing tools, I checked the server logs one morning and saw something uncomfortable: requests from IPs I didn't recognise, probing my tool endpoints. Anyone who found the URL could call my tools — publish articles, update content, read private data — with zero friction. That needed to change immediately.
This guide walks through implementing OAuth 2.1 protection for your Next.js MCP server that works with both Claude CLI and Claude Web. I'm covering the full implementation including the parts most guides skip: the consent page HTML, the complete refresh token flow, and the specific fixes for the errors you'll actually hit. Tested against mcp-handler v0.x and Claude's OAuth implementation as of late 2025.
Why OAuth Instead of API Keys
The obvious question when you first want to secure an MCP server is: why not just check for a static Authorization: Bearer my-secret-key header? It would be simpler and take ten minutes.
The answer is that Claude's clients don't support static API keys. Claude CLI and Claude Web both implement OAuth 2.1 specifically. When your MCP server returns a 401, Claude's clients expect to find a WWW-Authenticate header pointing to OAuth metadata. They will then discover your authorization server, register themselves as a client, open a browser for authorization, and exchange a code for tokens. There is no alternative path.
So this isn't a choice between OAuth and something simpler. It's a choice between implementing OAuth correctly and having an MCP server that Claude's clients can't authenticate against at all.
The benefit of OAuth 2.1 over a static key goes beyond Claude compatibility. It supports token expiry and refresh, so credentials rotate automatically. It gives you a proper authorization UI where you control what's shown to users. And it handles the difference between Claude CLI (which opens a browser) and Claude Web (which handles authorization inline) through the same protocol.
Why Your MCP Server Needs OAuth
When you deploy an MCP server, it becomes a public HTTP endpoint. Without authentication, anyone who discovers the URL has full access to every tool you've registered. In my case that meant the ability to publish articles directly to my blog, modify existing content, and query private data — all without any credentials. A single bot scan away from a bad day.
OAuth 2.1 is the right solution here for several reasons. Claude's MCP clients specifically expect OAuth for authentication. The protocol handles both programmatic access from Claude CLI and browser-based authorization for Claude Web. Token refresh works automatically, so users don't need to re-authenticate constantly.
Understanding the OAuth Flow
Before diving into code, let me explain how Claude's MCP authentication actually works. The flow differs slightly between CLI and Web, but both use OAuth 2.1 with specific requirements.
When Claude CLI connects to your MCP server, it first receives a 401 response with a WWW-Authenticate header pointing to your OAuth metadata. The CLI then discovers your authorization server, registers itself as a client using Dynamic Client Registration, opens a browser for user authorization, and exchanges the authorization code for access tokens.
Claude Web follows a similar path but handles the browser authorization inline. Both require your server to implement several OAuth endpoints and follow specific conventions I'll cover in detail.
A note on versioning: the MCP spec and Claude's OAuth implementation have been evolving quickly. This guide reflects the implementation that works as of late 2025. If you're reading this significantly later, check the mcp-handler changelog for any breaking changes to the withMcpAuth API.
TL;DR — The 6 Endpoints You Need
If you're partially through an implementation and just need to orient, here's what you're building:
| Endpoint | Method | Purpose | Required by |
|---|---|---|---|
/.well-known/oauth-authorization-server | GET | Advertises all OAuth endpoint URLs | Both CLI and Web |
/.well-known/oauth-protected-resource | GET | Tells clients your MCP route requires auth | Both CLI and Web |
/oauth/register | POST | Dynamic Client Registration for CLI | Claude CLI |
/oauth/authorize | GET | Shows consent screen to user | Both CLI and Web |
/oauth/authorize/approve | GET | Generates auth code after user approval | Both CLI and Web |
/oauth/token | POST | Exchanges code for tokens; handles refresh | Both CLI and Web |
All six need to exist and return the correct shapes. Missing any one of them produces errors that are difficult to diagnose without knowing what to look for.
Setting Up the OAuth Endpoints
OAuth Metadata Endpoints
First, create the authorization server metadata endpoint. This tells clients where to find all your OAuth endpoints.
// File: src/app/(non-intl)/.well-known/oauth-authorization-server/route.ts
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const url = new URL(request.url)
const protocol = process.env.NODE_ENV === 'production' ? 'https' : url.protocol.replace(':', '')
const baseUrl = `${protocol}://${url.host}`
const metadata = {
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/oauth/authorize`,
token_endpoint: `${baseUrl}/oauth/token`,
registration_endpoint: `${baseUrl}/oauth/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
scopes_supported: ['read:articles', 'write:articles', 'openid', 'email', 'profile'],
}
return NextResponse.json(metadata, {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
})
}
Notice token_endpoint_auth_methods_supported includes client_secret_post. This is crucial because Claude Web specifically uses this method in the token exchange. Missing it caused one of my longer debugging sessions.
Also notice registration_endpoint is included in the metadata. This is what tells Claude CLI that your server supports Dynamic Client Registration. If this field is absent, you'll get the "incompatible auth server" error covered in the troubleshooting section below.
Next, create the protected resource metadata endpoint.
// File: src/app/(non-intl)/.well-known/oauth-protected-resource/route.ts
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const url = new URL(request.url)
const protocol = process.env.NODE_ENV === 'production' ? 'https' : url.protocol.replace(':', '')
const baseUrl = `${protocol}://${url.host}`
const metadata = {
resource: `${baseUrl}/api/mcp`,
authorization_servers: [baseUrl],
scopes_supported: ['read:articles', 'write:articles', 'openid', 'email', 'profile'],
bearer_methods_supported: ['header'],
}
return NextResponse.json(metadata, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
})
}
This endpoint must return 200, not 401. Some OAuth implementations return 401 from the protected resource metadata, but Claude Web gets confused by this and fails to proceed with the authorization flow.
Dynamic Client Registration
Claude CLI uses Dynamic Client Registration to obtain client credentials automatically. It calls your registration endpoint before starting the authorization flow.
// File: src/app/(non-intl)/oauth/register/route.ts
import { NextResponse } from 'next/server'
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
})
export async function POST(request: Request) {
try {
const body = await request.json()
const clientId = process.env.MCP_CLIENT_ID
const clientSecret = process.env.MCP_CLIENT_SECRET
if (!clientId || !clientSecret) {
return NextResponse.json({
error: 'server_error',
error_description: 'OAuth not configured'
}, { status: 500 })
}
const clientData = {
client_id: clientId,
client_name: body.client_name || 'Claude',
redirect_uris: body.redirect_uris || ['https://claude.ai/api/mcp/auth_callback'],
grant_types: body.grant_types || ['authorization_code', 'refresh_token'],
response_types: body.response_types || ['code'],
token_endpoint_auth_method: 'client_secret_post',
scope: body.scope || 'openid email profile',
created_at: Date.now(),
}
await redis.set(`oauth:client:${clientId}`, JSON.stringify(clientData), { ex: 86400 * 365 })
const response: Record<string, any> = {
client_id: clientId,
client_secret: clientSecret,
client_id_issued_at: Math.floor(Date.now() / 1000),
redirect_uris: clientData.redirect_uris,
token_endpoint_auth_method: 'client_secret_post',
grant_types: clientData.grant_types,
response_types: clientData.response_types,
scope: clientData.scope,
}
if (body.client_name) {
response.client_name = body.client_name
}
return NextResponse.json(response, {
status: 201,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
'Access-Control-Allow-Origin': '*',
}
})
} catch (error) {
return NextResponse.json({
error: 'invalid_client_metadata',
error_description: 'Failed to register client'
}, { status: 400 })
}
}
The response must not contain null values for any field. Claude Web silently fails when it encounters null values in the registration response. Only include fields that have real values.
The Authorization Endpoint
This is where users authorize Claude to access your MCP server. The endpoint needs to render an actual HTML consent page — here's the full implementation including the helper functions that most guides leave out.
// File: src/app/(non-intl)/oauth/authorize/route.ts
import { NextResponse } from 'next/server'
function renderErrorPage(message: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Authorization Error</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 80px auto; padding: 0 24px; }
.error { background: #fef2f2; border: 1px solid #fca5a5; border-radius: 8px; padding: 20px; }
h1 { color: #dc2626; font-size: 18px; margin: 0 0 8px; }
p { color: #374151; margin: 0; }
</style>
</head>
<body>
<div class="error">
<h1>Authorization Error</h1>
<p>${message}</p>
</div>
</body>
</html>`
}
function renderConsentPage(params: {
clientId: string
redirectUri: string
scope: string
state: string | null
codeChallenge: string | null
codeChallengeMethod: string | null
}): string {
const approveUrl = new URL('/oauth/authorize/approve', 'https://placeholder')
approveUrl.searchParams.set('client_id', params.clientId)
approveUrl.searchParams.set('redirect_uri', params.redirectUri)
approveUrl.searchParams.set('scope', params.scope)
if (params.state) approveUrl.searchParams.set('state', params.state)
if (params.codeChallenge) approveUrl.searchParams.set('code_challenge', params.codeChallenge)
if (params.codeChallengeMethod) approveUrl.searchParams.set('code_challenge_method', params.codeChallengeMethod)
const scopeLabels: Record<string, string> = {
'read:articles': 'Read blog articles',
'write:articles': 'Create and edit blog articles',
'openid': 'Verify your identity',
'email': 'Access your email address',
'profile': 'Access your profile information',
}
const scopeList = params.scope.split(' ')
.filter(s => s)
.map(s => `<li>${scopeLabels[s] || s}</li>`)
.join('')
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Authorize Claude</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 80px auto; padding: 0 24px; }
.card { border: 1px solid #e5e7eb; border-radius: 12px; padding: 32px; }
h1 { font-size: 20px; margin: 0 0 8px; }
p { color: #6b7280; margin: 0 0 20px; font-size: 14px; }
ul { margin: 0 0 28px; padding-left: 20px; color: #374151; font-size: 14px; line-height: 1.8; }
.btn { display: inline-block; background: #1d4ed8; color: white; text-decoration: none;
padding: 10px 24px; border-radius: 6px; font-size: 15px; font-weight: 500; }
.deny { display: inline-block; margin-left: 16px; color: #6b7280; font-size: 14px; text-decoration: none; }
</style>
</head>
<body>
<div class="card">
<h1>Authorize Claude</h1>
<p>Claude is requesting access to your MCP server with the following permissions:</p>
<ul>${scopeList}</ul>
<a href="${approveUrl.pathname}${approveUrl.search}" class="btn">Authorize</a>
<a href="/" class="deny">Cancel</a>
</div>
</body>
</html>`
}
export async function GET(request: Request) {
const url = new URL(request.url)
const clientId = url.searchParams.get('client_id')
const redirectUri = url.searchParams.get('redirect_uri')
const responseType = url.searchParams.get('response_type')
const scope = url.searchParams.get('scope')
const state = url.searchParams.get('state')
const codeChallenge = url.searchParams.get('code_challenge')
const codeChallengeMethod = url.searchParams.get('code_challenge_method')
if (!clientId || !redirectUri || responseType !== 'code') {
return new NextResponse(renderErrorPage('Missing required parameters'), {
status: 400,
headers: { 'Content-Type': 'text/html' }
})
}
const expectedClientId = process.env.MCP_CLIENT_ID
if (clientId !== expectedClientId) {
return new NextResponse(renderErrorPage('Unknown client'), {
status: 401,
headers: { 'Content-Type': 'text/html' }
})
}
const html = renderConsentPage({
clientId,
redirectUri,
scope: scope || 'read:articles write:articles',
state,
codeChallenge,
codeChallengeMethod,
})
return new NextResponse(html, {
status: 200,
headers: { 'Content-Type': 'text/html' }
})
}
The renderConsentPage function builds the approve URL as a link rather than a form POST. This is intentional — the approval endpoint uses GET, not POST, for reasons I'll cover next.
// File: src/app/(non-intl)/oauth/authorize/approve/route.ts
import { NextResponse } from 'next/server'
import { randomBytes } from 'crypto'
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
})
export async function GET(request: Request) {
const url = new URL(request.url)
const clientId = url.searchParams.get('client_id')
const redirectUri = url.searchParams.get('redirect_uri')
const scope = url.searchParams.get('scope')
const state = url.searchParams.get('state')
const codeChallenge = url.searchParams.get('code_challenge')
const codeChallengeMethod = url.searchParams.get('code_challenge_method')
if (!clientId || !redirectUri) {
return NextResponse.json({ error: 'Missing parameters' }, { status: 400 })
}
const authCode = randomBytes(32).toString('hex')
const codeData = {
clientId,
redirectUri,
scope: scope || 'read:articles write:articles',
codeChallenge,
codeChallengeMethod,
createdAt: Date.now(),
}
await redis.set(`oauth:code:${authCode}`, JSON.stringify(codeData), { ex: 600 })
const redirectUrl = new URL(redirectUri)
redirectUrl.searchParams.set('code', authCode)
if (state) redirectUrl.searchParams.set('state', state)
return NextResponse.redirect(redirectUrl.toString())
}
This is a GET endpoint, not POST. I initially implemented approval as a form submission, but mcp-remote's callback server only accepts GET requests. The result was a "Cannot POST /oauth/callback" error that took me a while to track down. Using a link-based approve URL in the consent page sidesteps this entirely.
The Token Endpoint
The token endpoint exchanges authorization codes for access tokens and handles token refresh. Most guides leave the refresh token implementation as an exercise for the reader. Here's the complete version.
// File: src/app/(non-intl)/oauth/token/route.ts
import { NextResponse } from 'next/server'
import { randomBytes, createHash } from 'crypto'
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
})
function getClientCredentials(request: Request, body: URLSearchParams) {
const authHeader = request.headers.get('Authorization')
if (authHeader?.startsWith('Basic ')) {
const base64 = authHeader.slice(6)
const decoded = Buffer.from(base64, 'base64').toString('utf8')
const [clientId, clientSecret] = decoded.split(':')
return { clientId, clientSecret }
}
return {
clientId: body.get('client_id'),
clientSecret: body.get('client_secret'),
}
}
export async function POST(request: Request) {
const body = await request.text()
const params = new URLSearchParams(body)
const grantType = params.get('grant_type')
const code = params.get('code')
const codeVerifier = params.get('code_verifier')
const refreshToken = params.get('refresh_token')
const { clientId, clientSecret } = getClientCredentials(request, params)
if (clientId !== process.env.MCP_CLIENT_ID || clientSecret !== process.env.MCP_CLIENT_SECRET) {
return NextResponse.json({
error: 'invalid_client',
error_description: 'Invalid client credentials'
}, { status: 401 })
}
if (grantType === 'authorization_code') {
if (!code) {
return NextResponse.json({ error: 'invalid_request', error_description: 'Missing authorization code' }, { status: 400 })
}
const codeDataStr = await redis.get(`oauth:code:${code}`)
if (!codeDataStr) {
return NextResponse.json({ error: 'invalid_grant', error_description: 'Invalid or expired authorization code' }, { status: 400 })
}
const codeData = typeof codeDataStr === 'string' ? JSON.parse(codeDataStr) : codeDataStr
if (codeData.codeChallenge && codeData.codeChallengeMethod === 'S256') {
if (!codeVerifier) {
return NextResponse.json({ error: 'invalid_request', error_description: 'code_verifier required' }, { status: 400 })
}
const expectedChallenge = createHash('sha256').update(codeVerifier).digest('base64url')
if (expectedChallenge !== codeData.codeChallenge) {
return NextResponse.json({ error: 'invalid_grant', error_description: 'PKCE verification failed' }, { status: 400 })
}
}
await redis.del(`oauth:code:${code}`)
const accessToken = randomBytes(32).toString('hex')
const newRefreshToken = randomBytes(32).toString('hex')
const expiresIn = 3600
const tokenData = { clientId, scope: codeData.scope, createdAt: Date.now() }
await redis.set(`oauth:access:${accessToken}`, JSON.stringify(tokenData), { ex: expiresIn })
await redis.set(`oauth:refresh:${newRefreshToken}`, JSON.stringify({ ...tokenData, accessToken }), { ex: 86400 * 30 })
return NextResponse.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: expiresIn,
refresh_token: newRefreshToken,
scope: codeData.scope,
}, { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } })
}
if (grantType === 'refresh_token') {
if (!refreshToken) {
return NextResponse.json({ error: 'invalid_request', error_description: 'Missing refresh token' }, { status: 400 })
}
const refreshDataStr = await redis.get(`oauth:refresh:${refreshToken}`)
if (!refreshDataStr) {
return NextResponse.json({ error: 'invalid_grant', error_description: 'Invalid or expired refresh token' }, { status: 400 })
}
const refreshData = typeof refreshDataStr === 'string' ? JSON.parse(refreshDataStr) : refreshDataStr
// Rotate: delete old tokens
await redis.del(`oauth:refresh:${refreshToken}`)
if (refreshData.accessToken) {
await redis.del(`oauth:access:${refreshData.accessToken}`)
}
// Issue new tokens
const newAccessToken = randomBytes(32).toString('hex')
const newRefreshToken = randomBytes(32).toString('hex')
const expiresIn = 3600
const tokenData = { clientId: refreshData.clientId, scope: refreshData.scope, createdAt: Date.now() }
await redis.set(`oauth:access:${newAccessToken}`, JSON.stringify(tokenData), { ex: expiresIn })
await redis.set(`oauth:refresh:${newRefreshToken}`, JSON.stringify({ ...tokenData, accessToken: newAccessToken }), { ex: 86400 * 30 })
return NextResponse.json({
access_token: newAccessToken,
token_type: 'Bearer',
expires_in: expiresIn,
refresh_token: newRefreshToken,
scope: refreshData.scope,
}, { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } })
}
return NextResponse.json({ error: 'unsupported_grant_type' }, { status: 400 })
}
Two things to note. The Upstash Redis client returns objects directly, not JSON strings — the typeof check before JSON.parse handles this. The refresh token rotation pattern deletes both the old refresh token and old access token when a refresh occurs, so leaked refresh tokens can't be replayed.
Integrating Token Verification with Your MCP Route
With the OAuth endpoints in place, modify your MCP route to verify tokens on incoming requests.
// File: src/app/api/mcp/[transport]/route.ts
import { createMcpHandler, withMcpAuth } from 'mcp-handler'
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js'
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
})
const verifyToken = async (
req: Request,
bearerToken?: string
): Promise<AuthInfo | undefined> => {
if (!bearerToken) return undefined
const tokenDataStr = await redis.get(`oauth:access:${bearerToken}`)
if (!tokenDataStr) return undefined
const tokenData = typeof tokenDataStr === 'string'
? JSON.parse(tokenDataStr)
: tokenDataStr
return {
token: bearerToken,
scopes: tokenData.scope?.split(' ') || ['read:articles', 'write:articles'],
clientId: tokenData.clientId,
}
}
const handler = createMcpHandler(
// ... your server setup and tools
)
const authHandler = withMcpAuth(handler, verifyToken, {
required: true,
resourceMetadataPath: '/.well-known/oauth-protected-resource',
})
export { authHandler as GET, authHandler as POST }
The required: true setting is essential. Without it, the MCP route will accept unauthenticated requests and Claude CLI will not prompt for authentication.
Critical Configuration
Excluding OAuth Routes from i18n
If you're using next-intl or similar i18n routing, your OAuth routes will get prefixed with locale codes. Instead of /oauth/authorize, users hit /en/oauth/authorize, which returns 404.
Update your middleware to exclude OAuth paths:
// File: src/middleware.ts
export const config = {
matcher:
'/((?!api|trpc|_next|_vercel|oauth|\\.well-known|.*\\..*).*)'
}
This was one of my most frustrating debugging sessions. The OAuth flow would start, open the browser, but immediately show 404. The browser URL had /en/ prepended, which I didn't notice at first.
Vercel Firewall Configuration
Vercel's Security Checkpoint can block requests to your OAuth endpoints, treating them as bot traffic. When this happens, Claude Web gets an HTML challenge page instead of JSON metadata, and the OAuth flow fails silently.
In your Vercel project settings, add firewall exceptions for:
/.well-known/*/oauth/*
Environment Variables
MCP_CLIENT_ID=your-generated-client-id
MCP_CLIENT_SECRET=your-generated-client-secret
KV_REST_API_URL=your-upstash-redis-url
KV_REST_API_TOKEN=your-upstash-redis-token
Generate secure client credentials:
openssl rand -hex 16 # for client ID
openssl rand -hex 32 # for client secret
For Claude CLI, also set these in your shell profile:
export MCP_REMOTE_CLIENT_ID="your-client-id"
export MCP_REMOTE_CLIENT_SECRET="your-client-secret"
Redis Key Structure and TTLs
If you need to debug the OAuth flow directly, here's what gets written to Redis and how long it lives:
| Key pattern | TTL | Contains |
|---|---|---|
oauth:client:{clientId} | 365 days | Registered client metadata |
oauth:code:{code} | 10 minutes | Auth code, PKCE challenge, scope |
oauth:access:{token} | 1 hour | Client ID, scope, creation time |
oauth:refresh:{token} | 30 days | Client ID, scope, associated access token |
A successful auth flow will show keys in oauth:access:* and oauth:refresh:*. If you see oauth:code:* persisting after a token exchange, the code deletion step is failing.
Testing Your Implementation
Before involving any Claude client, verify the metadata endpoints are reachable:
# Test authorization server metadata
curl https://yourdomain.com/.well-known/oauth-authorization-server | jq .
# Test protected resource metadata
curl https://yourdomain.com/.well-known/oauth-protected-resource | jq .
Both should return JSON with status 200. If either returns HTML (a Vercel security challenge) or 404 (an i18n routing issue), fix those before proceeding.
Testing with Claude CLI
Clear any cached authentication and test the connection:
rm -rf ~/.mcp-auth
When you next use your MCP server, Claude should open a browser for authorization. After clicking Authorize, the browser shows "Authorization successful" and you can return to the CLI.
Testing with Claude Web
In Claude Web, go to Settings, then Connectors, and add a custom connector with your server URL:
https://www.yourdomain.com/api/mcp/sse
Use the www subdomain if your domain redirects to it. A redirect from non-www to www can strip headers and break the OAuth flow.
Fix: "Error: Incompatible Auth Server: Does Not Support Dynamic Client Registration"
This is the most common error when setting up MCP OAuth. The full error string is:
Error: Incompatible auth server: does not support dynamic client registration
There are three distinct causes, each with a different fix.
Cause 1: The /oauth/register endpoint doesn't exist.
Create the registration endpoint following the implementation above. Verify it's reachable with:
curl -X POST https://yourdomain.com/oauth/register \
-H "Content-Type: application/json" \
-d '{}'
It should return a 201 with client credentials, not a 404.
Cause 2: The endpoint exists but isn't advertised in metadata.
Your /.well-known/oauth-authorization-server response must include "registration_endpoint": "https://yourdomain.com/oauth/register". If this field is absent, Claude CLI sees an authorization server but has no way to register. Check your metadata endpoint with curl and confirm the field is present.
Cause 3: The registration endpoint is being blocked by Vercel.
The Security Checkpoint may be treating the POST to /oauth/register as bot traffic and returning HTML instead of JSON. Add /oauth/* to your Vercel firewall exceptions. You can confirm this is the issue by checking what curl -X POST https://yourdomain.com/oauth/register actually returns — HTML means the firewall is blocking it.
Common Issues and Solutions
Browser opens but shows 404. Check whether your i18n middleware is redirecting OAuth routes. Look at the actual URL in the browser — if it has a locale prefix like /en/, update your middleware matcher.
"Cannot POST /oauth/callback". Your authorization approval is using a form POST instead of a GET redirect. The consent page Authorize button should use an anchor tag linking to the approve URL, not a form submission.
Tokens issued but MCP endpoint still returns 401. The Upstash Redis client may be returning objects instead of strings. Add the typeof check before JSON.parse in both your token endpoint and verifyToken function. Also verify the Redis key format in your token endpoint matches what verifyToken is looking up.
Claude Web connects but tools fail immediately. Check your Vercel firewall settings. The .well-known endpoints may be blocked, causing Claude Web to receive an HTML challenge page when it tries to verify auth on each request.
"Invalid OAuth request: missing redirect_uri parameter". The consent page isn't passing redirect_uri through to the approve endpoint. Check that your renderConsentPage function includes redirect_uri in the approve URL's query string.
"Invalid OAuth request: missing scope parameter". The authorization request didn't include a scope parameter and your authorize endpoint is passing undefined instead of a default. Add a fallback: scope: scope || 'read:articles write:articles'.
Wrapping Up
Implementing OAuth for an MCP server is more involved than most OAuth implementations because Claude's clients have specific requirements that most frameworks don't anticipate. The combination of Dynamic Client Registration, PKCE, browser-based consent, and the specific token exchange format requires all six endpoints to work together correctly.
If you're debugging something not covered above, the Redis key inspection and metadata endpoint curl tests are usually the fastest way to narrow down where the flow is breaking. A working flow produces oauth:access:* and oauth:refresh:* keys in Redis immediately after the browser authorization step.
With OAuth in place, your MCP server is production-ready.
Let me know in the comments if you hit something not covered here, and subscribe for more practical development guides.
Thanks, Matija
Complete the Series
Extend your MCP server:
- Part 4: Send Emails from MCP — Add email automation with React Email and Brevo
- Custom JSON-RPC Implementation — Alternative to mcp-handler that avoids 406 errors
- MCP Integration: Claude vs OpenAI — Compare platforms and understand why Claude's approach is better
Or start from the beginning:
- Why Your Business Needs an MCP Server — The business case and use cases
- Part 1: Build a Production MCP Server — Foundation setup
- Part 2: Write Operations — Content editing capabilities
Want to learn more?
This article is part of our comprehensive "Building MCP Servers with Next.js" series.
Frequently Asked Questions
Comments
You might be interested in

30th November 2025

30th November 2025

6th December 2025

10th December 2025

20th December 2025