BuildWithMatija
Get In Touch
Part 3·Building MCP Servers with Next.js
  1. Home
  2. Blog
  3. Next.js
  4. 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

7th December 2025·Updated on:3rd January 2026·MŽMatija Žiberna·
Next.js
OAuth for MCP Server: Complete Guide to Protecting Claude

⚡ 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.

No spam. Unsubscribe anytime.

Related Posts:

  • •Build an MCP Server in Next.js for Claude Code (Complete Guide)
  • •Build a Production MCP Server in Next.js — Quick Guide
  • •Expanding Your Next.js MCP Server — Editing & Revalidation
  • •MCP Server For Business: Why Does Your Company Need It
  • •Send Emails from MCP with React Email & Brevo — Guide

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:

EndpointMethodPurposeRequired by
/.well-known/oauth-authorization-serverGETAdvertises all OAuth endpoint URLsBoth CLI and Web
/.well-known/oauth-protected-resourceGETTells clients your MCP route requires authBoth CLI and Web
/oauth/registerPOSTDynamic Client Registration for CLIClaude CLI
/oauth/authorizeGETShows consent screen to userBoth CLI and Web
/oauth/authorize/approveGETGenerates auth code after user approvalBoth CLI and Web
/oauth/tokenPOSTExchanges code for tokens; handles refreshBoth 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 patternTTLContains
oauth:client:{clientId}365 daysRegistered client metadata
oauth:code:{code}10 minutesAuth code, PKCE challenge, scope
oauth:access:{token}1 hourClient ID, scope, creation time
oauth:refresh:{token}30 daysClient 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
📄View markdown version

Want to learn more?

This article is part of our comprehensive "Building MCP Servers with Next.js" series.

View full series
0

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

Stay updated! Get our weekly digest with the latest learnings on NextJS, React, AI, and web development tips delivered straight to your inbox.

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in

Build an MCP Server in Next.js for Claude Code (Complete Guide)
Build an MCP Server in Next.js for Claude Code (Complete Guide)

30th November 2025

Build a Production MCP Server in Next.js — Quick Guide
Build a Production MCP Server in Next.js — Quick Guide

30th November 2025

Expanding Your Next.js MCP Server — Editing & Revalidation
Expanding Your Next.js MCP Server — Editing & Revalidation

6th December 2025

MCP Server For Business: Why Does Your Company Need It
MCP Server For Business: Why Does Your Company Need It

10th December 2025

Send Emails from MCP with React Email & Brevo — Guide
Send Emails from MCP with React Email & Brevo — Guide

20th December 2025

Table of Contents

  • Why OAuth Instead of API Keys
  • Why Your MCP Server Needs OAuth
  • Understanding the OAuth Flow
  • TL;DR — The 6 Endpoints You Need
  • Setting Up the OAuth Endpoints
  • OAuth Metadata Endpoints
  • Dynamic Client Registration
  • The Authorization Endpoint
  • The Token Endpoint
  • Integrating Token Verification with Your MCP Route
  • Critical Configuration
  • Excluding OAuth Routes from i18n
  • Vercel Firewall Configuration
  • Environment Variables
  • Redis Key Structure and TTLs
  • Testing Your Implementation
  • Testing with Claude CLI
  • Testing with Claude Web
  • Fix: "Error: Incompatible Auth Server: Does Not Support Dynamic Client Registration"
  • Common Issues and Solutions
  • Wrapping Up
  • Complete the Series
On this page:
  • Why OAuth Instead of API Keys
  • Why Your MCP Server Needs OAuth
  • Understanding the OAuth Flow
  • TL;DR — The 6 Endpoints You Need
  • Setting Up the OAuth Endpoints
Build With Matija Logo

Build with Matija

Matija Žiberna

I turn scattered business knowledge into one usable system. End-to-end system architecture, AI integration, and development.

Quick Links

Projects
  • How I Work
  • Blog
  • RSS Feed
  • Services

    • Payload CMS Websites
    • Bespoke AI Applications
    • Advisory

    Payload

    • Payload CMS Websites
    • Payload CMS Developer
    • Audit
    • Migration
    • Pricing
    • Payload vs Sanity
    • Payload vs WordPress
    • Payload vs Strapi
    • Payload vs Contentful

    Industries

    • Manufacturing
    • Construction

    Get in Touch

    Have a project in mind? Let's discuss how we can help your business grow.

    Book a discovery callContact me →
    © 2026BuildWithMatija•Principal-led system architecture•All rights reserved