All posts

MCP App Authentication: How to Add OAuth 2.1 to Your MCP App

Abe Wheeler
MCP Apps MCP App Framework ChatGPT Apps ChatGPT App Framework Claude Connectors Claude Connector Framework Claude Apps Authentication OAuth
Add OAuth 2.1 authentication to your MCP App for ChatGPT, Claude, and other hosts.

Add OAuth 2.1 authentication to your MCP App for ChatGPT, Claude, and other hosts.

TL;DR: MCP Apps use OAuth 2.1 for authentication. The host (ChatGPT, Claude) handles the OAuth flow with your identity provider and sends Bearer tokens with every MCP request. On your server, validate the token in an auth() function and use the verified identity in your tool handlers. You need a discovery document at /.well-known/oauth-protected-resource and an OAuth provider that supports PKCE and dynamic client registration.


Most MCP Apps start without authentication. You build a tool, render a resource, and test it locally. Then you need user-specific data, and the question becomes: how do I know which user is asking?

The answer is OAuth 2.1. The MCP specification defines OAuth 2.1 as the standard for authenticating users against protected MCP servers. Your MCP App acts as an OAuth resource server. The AI host (ChatGPT, Claude, VS Code) acts as the OAuth client. An identity provider you choose (Auth0, Okta, Cognito, your own) issues the tokens.

This guide covers how the auth flow works end to end, how to set it up on your server, how ChatGPT and Claude each handle the flow, and how to test it all locally.

When You Need Auth

Not every MCP App needs authentication. Whether you need it depends on what data your tools return and whether you need to distinguish between users.

You don’t need auth if:

  • Your tools return public data (weather, stock prices, documentation lookups)
  • All users see the same content
  • You’re building an internal tool where all users share one credential

You need auth if:

  • Your tools return private, per-user data (emails, documents, CRM records, dashboards)
  • You need to scope database queries to the authenticated user
  • You’re submitting to the ChatGPT App Directory or Claude Connectors Directory and your app accesses private data
  • You need audit logs that identify who called which tool

Without auth, the AI host sends no user identity to your server. No user ID, no email, no session cookie. Your tool handlers have no way to know who’s asking. If you need that, OAuth is the only mechanism supported by the MCP spec.

How the OAuth 2.1 Flow Works

OAuth 2.1 is an update to OAuth 2.0 that tightens security defaults. The changes that matter for MCP Apps:

  • PKCE is required for all clients, not just public ones. Every authorization request must include a code challenge and code verifier.
  • The implicit grant is removed. All token issuance goes through the authorization code flow.
  • Redirect URIs must match exactly. No wildcard or partial matching.

Here’s the flow when a user connects your MCP App to an AI host:

  1. The user clicks “Connect” (or “Enable,” or “Add”) in the host UI.
  2. The host fetches your server’s protected resource metadata at /.well-known/oauth-protected-resource to discover your authorization server.
  3. The host fetches the authorization server’s OpenID discovery document at /.well-known/openid-configuration (or /.well-known/oauth-authorization-server) to learn the auth endpoints.
  4. If the host doesn’t already have credentials for your auth server, it registers itself as a client via dynamic client registration.
  5. The host starts the authorization code + PKCE flow: generates a code verifier and challenge, then redirects the user to your authorization server’s authorization_endpoint.
  6. The user signs in and grants consent at your identity provider.
  7. The identity provider redirects back to the host’s callback URL with an authorization code.
  8. The host exchanges the code for tokens (access token + refresh token) at the token_endpoint.
  9. On every subsequent MCP request, the host attaches the access token in the Authorization: Bearer <token> header.

Your MCP server validates that token on every request. If the token is expired, the host uses the refresh token to get a new one. If the refresh token is expired, the user re-authenticates.

The Discovery Document

Your MCP server needs to tell hosts where to find your authorization server. You do this by serving a JSON document at /.well-known/oauth-protected-resource.

Here’s what the document looks like:

{
  "resource": "https://your-mcp-app.example.com",
  "authorization_servers": ["https://your-auth-provider.example.com"],
  "scopes_supported": ["read", "write", "profile"],
  "bearer_methods_supported": ["header"]
}

The authorization_servers array points to your OAuth provider. The host takes that URL and appends /.well-known/openid-configuration to fetch the auth endpoints (authorize, token, registration, etc.).

If you use a managed identity provider like Auth0, Okta, or Cognito, the provider already serves the OpenID discovery document. You only need to set up the protected resource metadata on your MCP server.

Server-Side Setup

On the server, authentication comes down to one function: validate the incoming Bearer token and return the user’s identity. In sunpeak, that’s the auth() function in src/server.ts.

import type { IncomingMessage } from 'node:http';
import type { AuthInfo } from 'sunpeak/mcp';

export async function auth(req: IncomingMessage): Promise<AuthInfo | null> {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) return null;

  const token = header.slice(7);
  const payload = await validateJwt(token);
  if (!payload) return null;

  return {
    token,
    clientId: payload.sub,
    scopes: payload.scope?.split(' ') ?? [],
  };
}

This function runs on every MCP request before any tool handler is called. Return null to reject the request with a 401. Return an AuthInfo object to allow it.

The AuthInfo you return is then available in every tool handler via extra.authInfo:

export default async function (args: Args, extra: ToolHandlerExtra) {
  const userId = extra.authInfo?.clientId;
  if (!userId) {
    return { content: [{ type: 'text', text: 'Not authenticated' }] };
  }

  const records = await db.getRecordsForUser(userId);
  return {
    structuredContent: {
      type: 'records-list',
      records,
    },
  };
}

Token Validation

The token you receive is a JWT (in most setups). Validating it means checking:

  1. Signature: Verify the JWT signature against your identity provider’s public keys (fetched from the JWKS endpoint).
  2. Issuer: Confirm the iss claim matches your identity provider.
  3. Audience: Confirm the aud claim matches your MCP server’s resource identifier.
  4. Expiry: Reject tokens where exp is in the past.
  5. Scopes: Optionally check that the token’s scopes match what the tool requires.

Most identity providers have SDK libraries that handle all of this. With Auth0, for example:

import { jwtVerify, createRemoteJWKSet } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL('https://your-tenant.auth0.com/.well-known/jwks.json')
);

async function validateJwt(token: string) {
  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: 'https://your-tenant.auth0.com/',
      audience: 'https://your-mcp-app.example.com',
    });
    return payload;
  } catch {
    return null;
  }
}

The jose library is a good default for JWT validation in Node.js. It supports remote JWKS fetching, all standard claims, and doesn’t pull in heavy dependencies.

How ChatGPT Handles Auth

ChatGPT acts as the OAuth public client on your user’s behalf. When a user adds your MCP App in ChatGPT (Settings > Apps & Connectors > Create), ChatGPT:

  1. Fetches /.well-known/oauth-protected-resource from your server.
  2. Fetches the OpenID discovery document from the authorization server listed in step 1.
  3. Calls the registration_endpoint to register itself as a dynamic client and get a client_id.
  4. Redirects the user to the authorization_endpoint with PKCE parameters.
  5. Receives the authorization code at ChatGPT’s callback URL.
  6. Exchanges the code for access and refresh tokens.
  7. Stores the tokens and attaches the access token to every MCP tool call.

One thing to know: window.openai tool calls from a resource component also carry the token, because they route through ChatGPT’s backend to your MCP server. If your resource makes direct fetch() calls to your own API (bypassing MCP), you’ll need to pass the token to the resource through your tool’s structuredContent and attach it as a Bearer header in your fetch call.

For ChatGPT App Directory submission, ChatGPT requires your OAuth setup to support dynamic client registration. If your identity provider doesn’t support it natively, you’ll need to implement the registration endpoint yourself or use a provider that does.

How Claude Handles Auth

Claude uses OAuth 2.0 authorization code flow with user consent. The flow is similar to ChatGPT’s but the specifics differ.

When a user enables your connector in Claude:

  1. Claude redirects the user to your OAuth authorization URL.
  2. The user signs in and grants consent.
  3. The identity provider redirects back to Claude’s callback URL with an authorization code.
  4. Claude exchanges the code for tokens and stores them.
  5. Every tool call includes the access token as a Bearer header.

You must allowlist both callback URLs in your OAuth provider:

  • https://claude.ai/api/mcp/auth_callback
  • https://claude.com/api/mcp/auth_callback

Missing the claude.com URL is a common reason Connectors Directory submissions fail review.

Claude does not support machine-to-machine (client credentials) OAuth. Every user must complete the interactive consent flow. If you need background server-to-server access, handle that inside your tool handlers with separate credentials. See the Claude Connector OAuth guide for more Claude-specific details.

Passing Auth Tokens to Resource Components

Your resource component runs in a sandboxed iframe. It doesn’t have direct access to the OAuth token that the host sends to your MCP server. If your resource needs to call an authenticated API directly (not through another tool call), you need to pass the token through.

The recommended pattern is to include auth information in your tool’s structuredContent:

// In your tool handler
export default async function (args: Args, extra: ToolHandlerExtra) {
  const token = extra.authInfo?.token;

  return {
    structuredContent: {
      apiToken: token,
      userId: extra.authInfo?.clientId,
      data: await fetchData(args, token),
    },
  };
}

Then in your resource component, use the token for subsequent API calls:

function MyResource() {
  const toolData = useToolData<ResourceData>();
  const { apiToken, data } = toolData ?? {};

  async function refreshData() {
    const response = await fetch('https://api.example.com/data', {
      headers: { Authorization: `Bearer ${apiToken}` },
    });
    // handle response
  }

  // render data...
}

Make sure you’ve added your API’s origin to connectDomains in the resource’s CSP config, or the browser will block the fetch.

This approach keeps the token out of the browser until the tool actually runs, and it works across both ChatGPT and Claude because the token passthrough happens in your server code, not in the host’s runtime.

Testing Auth Locally

Authentication adds a layer you need to test before deploying. There are several approaches depending on what you’re testing.

Test tool and resource behavior without auth

During development, you often want to work on tool logic and resource rendering separately from the auth flow. Skip auth by returning a default AuthInfo in your auth() function:

export async function auth(req: IncomingMessage): Promise<AuthInfo | null> {
  if (process.env.NODE_ENV === 'development') {
    return { token: 'dev', clientId: 'test-user', scopes: ['read', 'write'] };
  }

  // production validation...
}

This lets you develop tools and resources with a known user identity without wiring up a real OAuth provider. The sunpeak Inspector runs your full app locally with simulated host environments, so you can test resource rendering, tool behavior, and display modes without touching auth at all.

Test token validation logic

Write unit tests for your validateJwt function and your auth() function. Test the cases that matter: valid tokens, expired tokens, wrong issuer, missing scopes.

import { describe, it, expect } from 'vitest';

describe('auth', () => {
  it('rejects requests without Authorization header', async () => {
    const req = { headers: {} } as IncomingMessage;
    expect(await auth(req)).toBeNull();
  });

  it('rejects expired tokens', async () => {
    const req = {
      headers: { authorization: 'Bearer expired-token' },
    } as IncomingMessage;
    expect(await auth(req)).toBeNull();
  });

  it('returns AuthInfo for valid tokens', async () => {
    const req = {
      headers: { authorization: `Bearer ${validTestToken}` },
    } as IncomingMessage;
    const result = await auth(req);
    expect(result?.clientId).toBe('test-user-id');
  });
});

Test the full OAuth flow

To test the actual redirect flow end to end, expose your local server with a tool like ngrok:

ngrok http 8000

Then add the ngrok URL as a custom connector in ChatGPT or Claude. You’ll go through the real OAuth consent screen, and your local server will receive real Bearer tokens. This is the only way to verify the full chain: discovery document, client registration, consent, token exchange, and token validation.

Common Mistakes

Missing the discovery document. If your server doesn’t serve /.well-known/oauth-protected-resource, ChatGPT can’t discover your authorization server. The connection silently fails or shows a generic error. Check that the endpoint is reachable and returns valid JSON.

Wrong callback URLs for Claude. Claude uses two callback domains: claude.ai and claude.com. If your OAuth provider only allowlists one, half your users get redirect errors. Add both.

Validating only the token signature. Checking the JWT signature is necessary but not sufficient. You also need to verify the issuer, audience, and expiry. Without audience validation, a token issued for a different application at the same identity provider could pass your checks.

Not handling token refresh. Access tokens expire. The host handles refresh automatically using the refresh token, but your server needs to reject expired tokens cleanly (return null from auth()) rather than crashing or returning confusing errors. The host will refresh and retry.

Storing tokens in the resource iframe. Resource components run in sandboxed iframes. Storing tokens in localStorage or cookies inside the iframe is unreliable because hosts may clear iframe storage between tool calls. Pass tokens through structuredContent from your tool handler instead.

Forgetting PKCE. OAuth 2.1 requires PKCE for all clients. If your authorization server doesn’t support PKCE, ChatGPT’s dynamic client registration will fail. Most modern providers support it by default, but older or self-hosted providers might need a configuration change.

Skipping scopes. Requesting all scopes when you only need read access is a common reason directory submissions get rejected. Request the minimum scopes your tools actually need, and document them in your directory listing.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

Do MCP Apps require authentication?

No. Authentication is only required if your MCP App accesses private user data or needs to know which user is making a request. If your app returns public data or generic content, you can skip auth entirely. When you do need per-user data, OAuth 2.1 is the standard defined by the MCP specification.

What OAuth version do MCP Apps use?

MCP Apps use OAuth 2.1, which requires PKCE (Proof Key for Code Exchange) for all clients, drops the implicit grant entirely, and requires exact redirect URI matching. The MCP specification mandates OAuth 2.1 as the authorization standard for protected MCP servers.

How does ChatGPT handle MCP App authentication?

When a user connects your MCP App in ChatGPT, ChatGPT acts as the OAuth public client. It fetches your protected resource metadata, registers itself with your authorization server via dynamic client registration, runs the authorization code + PKCE flow, and attaches the access token to every subsequent MCP request as a Bearer token.

How does Claude handle MCP App authentication?

Claude uses a standard OAuth 2.0 authorization code flow with user consent. When a user enables your connector, Claude redirects them to your OAuth authorization URL. After consent, Claude stores the tokens and includes the access token on every tool call. You must allowlist both claude.ai and claude.com callback URLs.

What is a protected resource metadata document?

A JSON document served at /.well-known/oauth-protected-resource on your MCP server. It tells the host where to find your authorization server, what scopes are available, and how to register as a client. ChatGPT fetches this document automatically when a user connects your app.

Can I test MCP App authentication locally without deploying?

Yes. For basic auth validation, export an auth() function from src/server.ts in sunpeak and pass test tokens through tool handlers. For full OAuth flow testing, expose your local server with ngrok and connect it to ChatGPT or Claude as a custom connector. The sunpeak Inspector also lets you test tool and resource behavior without auth so you can develop the rest of your app independently.

What is the difference between auth() in sunpeak and OAuth 2.1?

The auth() function in src/server.ts is the server-side hook where you validate incoming tokens. OAuth 2.1 is the protocol that gets those tokens to your server in the first place. The host (ChatGPT or Claude) handles the OAuth flow with your identity provider, obtains tokens, and sends them to your server. Your auth() function receives and validates those tokens on every request.

Do I need separate OAuth setups for ChatGPT and Claude?

Yes. Each host has its own callback URLs and handles the OAuth flow independently. Your MCP server code is the same, but you register separate OAuth applications (or separate redirect URIs within one application) for each host. ChatGPT uses its own callback URL, and Claude uses https://claude.ai/api/mcp/auth_callback and https://claude.com/api/mcp/auth_callback.