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.
After building an MCP server for my blog and expanding it with content publishing tools, I realized something uncomfortable: anyone who discovered my server URL could use my tools. They could publish articles, update content, or access private data without any authentication. That needed to change.
This guide walks you through implementing OAuth 2.1 protection for your MCP server that works with both Claude CLI and Claude Web. If you have been following along with the basic MCP server setup and the content publishing expansion, this is the natural next step.
Fair warning: getting OAuth working with Claude's MCP clients was harder than expected. Claude CLI and Claude Web have specific requirements that are not well-documented, and I spent hours debugging issues that turned out to be subtle configuration problems. I am sharing everything I learned so you do not have to repeat my mistakes.
Why Your MCP Server Needs OAuth
When you deploy an MCP server, it becomes a public HTTP endpoint. The mcp-handler library handles the transport layer, but authentication is your responsibility. Without it, your powerful tools are just sitting there, waiting to be discovered and misused.
OAuth 2.1 is the right choice here for several reasons. Claude's MCP clients specifically expect OAuth for authentication. The protocol supports both programmatic access from Claude CLI and browser-based authorization for Claude Web. Plus, it handles token refresh automatically, so users do not need to re-authenticate constantly.
Understanding the OAuth Flow
Before diving into code, let me explain how Claude's MCP authentication works. The flow differs slightly between CLI and Web, but both use OAuth 2.1 with some 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 that I will cover in detail.
Setting Up the OAuth Endpoints
You will need to create six OAuth-related endpoints. Let me walk through each one, explaining what it does and why Claude needs it.
OAuth Metadata Endpoints
First, create the authorization server metadata endpoint. This tells clients where to find 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 the token_endpoint_auth_methods_supported array includes client_secret_post. This is crucial because Claude Web specifically uses this method in the token exchange. Missing this caused one of my debugging sessions.
Next, create the protected resource metadata endpoint. This tells clients that your MCP server is a protected resource and where to find the authorization server.
// 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': '*',
},
})
}
I want to highlight something important here. This endpoint should 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. This means 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 })
}
}
There is a subtle but important detail here. The response must not contain null values for any field. If you are tempted to include optional fields as null, don't. Claude Web silently fails when it encounters null values in the registration response. I only discovered this after reading through GitHub issues.
The Authorization Endpoint
This is where users authorize Claude to access your MCP server. For Claude CLI, this opens in a browser. For Claude Web, it happens inline.
// File: src/app/(non-intl)/oauth/authorize/route.ts
import { NextResponse } from 'next/server'
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 authorization page displays a consent screen with an Authorize button. When clicked, it redirects to an approval endpoint that generates the authorization code.
// 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 with POST, 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 debug.
The Token Endpoint
The token endpoint exchanges authorization codes for access tokens and handles token refresh.
// 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 redirectUri = params.get('redirect_uri')
const codeVerifier = params.get('code_verifier')
const refreshToken = params.get('refresh_token')
const { clientId, clientSecret } = getClientCredentials(request, params)
const expectedClientId = process.env.MCP_CLIENT_ID
const expectedClientSecret = process.env.MCP_CLIENT_SECRET
if (clientId !== expectedClientId || clientSecret !== expectedClientSecret) {
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: 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') {
// Handle refresh token grant - similar structure
// ...
}
return NextResponse.json({
error: 'unsupported_grant_type'
}, { status: 400 })
}
One thing that caught me: the Upstash Redis client returns objects directly, not JSON strings. If you use JSON.parse on the result, you get "[object Object] is not valid JSON". The fix is to check the type before parsing.
Integrating Token Verification with Your MCP Route
With the OAuth endpoints in place, you need to 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
const scopes = tokenData.scope?.split(' ') || ['read:articles', 'write:articles']
return {
token: bearerToken,
scopes: scopes,
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: Middleware and Environment
Excluding OAuth Routes from i18n
If you are 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 because the OAuth routes do not exist there.
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 did not 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.
Go to your Vercel project settings and add firewall exceptions for:
- /.well-known/*
- /oauth/*
Without these exceptions, Claude Web will appear to connect but fail immediately, with no useful error messages.
Environment Variables
Set these environment variables in your .env.local and Vercel:
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 using:
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"
Testing Your Implementation
Testing with Claude CLI
Clear any cached authentication and test the connection:
rm -rf ~/.mcp-auth
When you next use your MCP server from Claude, it should open a browser for authorization. After you click 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
Important: Use the www subdomain if your domain redirects to it. A redirect from the non-www to www version can strip headers and break the OAuth flow.
You can optionally enter your client ID and secret in Advanced Settings, but with Dynamic Client Registration implemented, Claude Web will obtain credentials automatically.
Common Issues and Solutions
Throughout this implementation, I encountered several issues that were difficult to debug. Here is a summary of the most common problems.
If Claude CLI says "Incompatible auth server: does not support dynamic client registration", you are missing the /oauth/register endpoint or it is not advertised in your authorization server metadata.
If the browser opens but shows 404, check whether your i18n middleware is redirecting OAuth routes. The URL in the browser might have an unexpected locale prefix.
If you see "Cannot POST /oauth/callback", your authorization approval is using form POST instead of GET redirect. Change the approval flow to use links that redirect via GET.
If tokens are issued but the MCP endpoint still returns 401, the Upstash Redis client might be returning objects instead of strings. Check for JSON.parse errors on already-parsed objects.
If Claude Web connects but tools fail immediately, check your Vercel firewall settings. The .well-known endpoints might be blocked by the Security Checkpoint.
Wrapping Up
Implementing OAuth for an MCP server is more complex than typical OAuth implementations because Claude's clients have specific requirements. The combination of Dynamic Client Registration, PKCE, and the browser-based authorization flow requires several endpoints to work together correctly.
The good news is that once configured, it works reliably. Your MCP tools are now protected, and both Claude CLI and Claude Web can authenticate smoothly. Users authorize once, and token refresh handles re-authentication automatically.
If you have been following along with this series, you now have a complete, production-ready MCP server. You can search articles, publish content, and update existing posts, all protected behind proper authentication. Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks, Matija