---
title: "OAuth for MCP Server: Complete Guide to Protecting Claude"
slug: "oauth-mcp-server-claude"
published: "2025-12-07"
updated: "2026-01-03"
categories:
  - "Next.js"
tags:
  - "OAuth 2.1 MCP server"
  - "secure MCP server authentication"
  - "Dynamic Client Registration MCP"
  - "PKCE Claude MCP"
  - "Claude Web OAuth setup"
  - "Claude CLI authentication"
  - "MCP token verification"
  - "protect MCP endpoints"
  - "Upstash Redis OAuth tokens"
  - "Vercel firewall OAuth"
llm-intent: "how-to"
audience-level: "advanced"
llm-purpose: "Implement complete OAuth 2.1 security for MCP servers that works with both Claude CLI and Claude Web"
llm-prereqs:
  - "Completed Part 1 and Part 2 of series"
  - "Upstash Redis for token storage"
  - "Understanding of OAuth concepts"
llm-outputs:
  - "OAuth 2.1 endpoints implemented"
  - "Dynamic Client Registration working"
  - "PKCE verification active"
  - "Claude CLI and Web both authenticated"
  - "Token refresh handling"
---

**Summary Triples**
- (OAuth for MCP Server: Complete Guide to Protecting Claude, expresses-intent, how-to)
- (OAuth for MCP Server: Complete Guide to Protecting Claude, covers-topic, OAuth 2.1 MCP server)
- (OAuth for MCP Server: Complete Guide to Protecting Claude, provides-guidance-for, Implement complete OAuth 2.1 security for MCP servers that works with both Claude CLI and Claude Web)

### {GOAL}
Implement complete OAuth 2.1 security for MCP servers that works with both Claude CLI and Claude Web

### {PREREQS}
- Completed Part 1 and Part 2 of series
- Upstash Redis for token storage
- Understanding of OAuth concepts

### {STEPS}
1. Why OAuth Matters for MCP
2. Create OAuth metadata endpoints
3. Implement Dynamic Client Registration
4. Build authorization and approval endpoints
5. Implement token endpoint with PKCE
6. Integrate token verification in MCP route
7. Configure middleware and Vercel firewall
8. Set environment variables and secrets
9. Test with Claude CLI and Web

<!-- llm:goal="Implement complete OAuth 2.1 security for MCP servers that works with both Claude CLI and Claude Web" -->
<!-- llm:prereq="Completed Part 1 and Part 2 of series" -->
<!-- llm:prereq="Upstash Redis for token storage" -->
<!-- llm:prereq="Understanding of OAuth concepts" -->
<!-- llm:output="OAuth 2.1 endpoints implemented" -->
<!-- llm:output="Dynamic Client Registration working" -->
<!-- llm:output="PKCE verification active" -->
<!-- llm:output="Claude CLI and Web both authenticated" -->
<!-- llm:output="Token refresh handling" -->

# OAuth for MCP Server: Complete Guide to Protecting Claude
> OAuth for MCP server: Secure Claude CLI & Web with OAuth 2.1, Dynamic Client Registration, PKCE, and token verification. Step-by-step setup and fixes.
Matija Žiberna · 2025-12-07

**This is Part 3 of the MCP Server Series.** Prerequisites: [Part 1: Build a Production MCP Server](/blog/build-mcp-server-nextjs) and [Part 2: Write Operations](/blog/expanding-mcp-server-nextjs). If you're debugging 406 errors with mcp-handler, check out [Custom JSON-RPC Implementation](/blog/custom-mcp-server-nextjs-json-rpc) 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.
```typescript
// 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.
```typescript
// 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.
```typescript
// 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.
```typescript
// 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.
```typescript
// 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.
```typescript
// 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.
```typescript
// 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:
```typescript
// 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:
```bash
openssl rand -hex 16  # for client ID
openssl rand -hex 32  # for client secret
```

For Claude CLI, also set these in your shell profile:
```bash
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:
```bash
# 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:
```bash
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:
```bash
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](/blog/send-emails-mcp-react-email-brevo)** — Add email automation with React Email and Brevo
- **[Custom JSON-RPC Implementation](/blog/custom-mcp-server-nextjs-json-rpc)** — Alternative to mcp-handler that avoids 406 errors
- **[MCP Integration: Claude vs OpenAI](/blog/mcp-server-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](/blog/why-your-business-needs-an-mcp-server)** — The business case and use cases
- **[Part 1: Build a Production MCP Server](/blog/build-mcp-server-nextjs)** — Foundation setup
- **[Part 2: Write Operations](/blog/expanding-mcp-server-nextjs)** — Content editing capabilities