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 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.
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.
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.
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.
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.
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.
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.
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.
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:
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:
code
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:
code
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:
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.