Implementing OAuth in MCP Servers: A Complete Security Guide
As MCP servers move from development to production environments, implementing robust authentication becomes critical. OAuth 2.1 provides the security framework needed to protect MCP servers while maintaining compatibility with AI clients. This guide shows you how to implement a complete OAuth solution for MCP servers.
Why OAuth is Essential for MCP Servers
MCP servers often access sensitive data and systems - from databases to file systems to APIs. Without proper authentication:
- Security Risks: Unauthorized access to protected resources
- Compliance Issues: Failure to meet enterprise security requirements
- Scalability Problems: No way to manage user permissions and sessions
- Audit Challenges: Lack of user attribution for actions
OAuth 2.1 solves these problems by providing secure, standardized authentication that works with any MCP client.
Understanding the OAuth 2.1 Flow for MCP Servers
The OAuth flow for MCP servers follows these key steps:
1. Discovery: Client discovers OAuth endpoints
2. Authorization: User grants permission to access resources
3. Token Exchange: Authorization code is exchanged for access tokens
4. Resource Access: Tokens authenticate MCP server requests
5. Token Refresh: Long-lived sessions through refresh tokens
Implementing OAuth Discovery Endpoints
OAuth clients need to discover your server's authentication capabilities. Implement these well-known endpoints:
Protected Resource Metadata
// GET /.well-known/oauth-protected-resource
export function handleProtectedResourceMetadata(req: IncomingMessage, res: ServerResponse): void {
const host = req.headers.host;
const protocol = req.headers['x-forwarded-proto'] || 'http';
const baseUrl = `${protocol}://${host}`;
const metadata = {
resource: `${baseUrl}/mcp`,
authorization_servers: [baseUrl]
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(metadata));
}
Authorization Server Metadata
// GET /.well-known/oauth-authorization-server
export function handleAuthorizationServerMetadata(req: IncomingMessage, res: ServerResponse): void {
const baseUrl = `${protocol}://${host}`;
const metadata = {
issuer: `${baseUrl}/auth/v1`,
authorization_endpoint: `${baseUrl}/api/oauth/authorize`,
token_endpoint: `${baseUrl}/api/oauth/token`,
jwks_uri: `${baseUrl}/.well-known/jwks.json`,
grant_types_supported: ["authorization_code", "refresh_token"],
code_challenge_methods_supported: ["S256"],
response_types_supported: ["code"],
scopes_supported: ["openid", "email", "profile"],
token_endpoint_auth_methods_supported: ["client_secret_post", "none"],
resource_indicators_supported: true,
require_pushed_authorization_requests: false
};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(metadata));
}
Dynamic Client Registration
Support dynamic client registration to handle various MCP clients:
// POST /.well-known/oauth-dynamic-client-registration
export function handleDynamicClientRegistration(req: IncomingMessage, res: ServerResponse): void {
const redirectUris = generateSecureRedirectUris();
const clientRegistration = {
client_id: "mcp-client",
client_secret: "", // Empty for public client compatibility
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
redirect_uris: redirectUris,
scope: "openid email profile",
client_name: "MCP Client",
application_type: "native"
};
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(clientRegistration));
}
Comprehensive Redirect URI Support
Support all major MCP clients with comprehensive redirect URIs:
function generateSecureRedirectUris(): string[] {
const redirectUris: string[] = [];
// 1. Localhost patterns for development
const localhostPorts = [3000, 3001, 5000, 5173, 8080, 8000, 4000, 6274];
const localhostPaths = ['/oauth/callback', '/callback', '/auth/callback'];
for (const port of localhostPorts) {
for (const path of localhostPaths) {
redirectUris.push(`http://localhost:${port}${path}`);
redirectUris.push(`http://127.0.0.1:${port}${path}`);
}
}
// 2. IDE Custom URI Schemes
const ideSchemes = [
// VS Code family
'vscode://oauth/callback',
'vscode://auth/callback',
'vscode-insiders://oauth/callback',
// Cursor AI Editor
'cursor://oauth/callback',
'cursor://auth/callback',
'cursor://mcp/oauth/callback',
// JetBrains family
'jetbrains://oauth/callback',
'intellij://oauth/callback',
'pycharm://oauth/callback',
'webstorm://oauth/callback',
// Other editors
'zed://oauth/callback',
'atom://oauth/callback',
'sublime://oauth/callback',
// MCP-specific schemes
'mcp://oauth/callback',
'mcp://auth/callback'
];
redirectUris.push(...ideSchemes);
return redirectUris;
}
Authorization Endpoint Implementation
Handle the authorization request with proper validation:
// GET /api/oauth/authorize
export async function handleAuthorize(request: NextRequest) {
const { searchParams } = new URL(request.url);
const response_type = searchParams.get('response_type');
const redirect_uri = searchParams.get('redirect_uri');
const scope = searchParams.get('scope');
const state = searchParams.get('state');
const code_challenge = searchParams.get('code_challenge');
const code_challenge_method = searchParams.get('code_challenge_method');
// Validate required parameters
if (!response_type || response_type !== 'code') {
return createOAuthErrorResponse(
'unsupported_response_type',
'Only authorization code flow is supported'
);
}
if (!redirect_uri) {
return createOAuthErrorResponse(
'invalid_request',
'redirect_uri is required'
);
}
// Validate PKCE parameters (required for OAuth 2.1)
if (!code_challenge || !code_challenge_method || code_challenge_method !== 'S256') {
return createOAuthErrorResponse(
'invalid_request',
'PKCE with S256 is required for security'
);
}
// Validate redirect URI
if (!isValidMCPRedirectUri(redirect_uri)) {
return createOAuthErrorResponse(
'invalid_request',
'Invalid redirect URI'
);
}
// Check authentication and consent...
// Implementation details depend on your auth system
}
Token Endpoint with Refresh Support
Implement token exchange with proper refresh token rotation:
// POST /api/oauth/token
export async function handleToken(request: NextRequest) {
const body = await request.json();
const { grant_type, code, redirect_uri, code_verifier, refresh_token } = body;
// Validate grant type
if (grant_type !== 'authorization_code' && grant_type !== 'refresh_token') {
return createOAuthErrorResponse(
'unsupported_grant_type',
'Only authorization_code and refresh_token grant types are supported'
);
}
if (grant_type === 'refresh_token') {
return handleRefreshTokenGrant(refresh_token);
}
// Handle authorization code grant
if (!code || !redirect_uri || !code_verifier) {
return createOAuthErrorResponse(
'invalid_request',
'Missing required parameters'
);
}
// Verify PKCE challenge
const isValidPKCE = await verifyPKCEChallenge(code, code_verifier);
if (!isValidPKCE) {
return createOAuthErrorResponse(
'invalid_grant',
'PKCE verification failed'
);
}
// Exchange code for tokens
const tokens = await exchangeAuthorizationCode(code, redirect_uri);
return NextResponse.json({
access_token: tokens.access_token,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: tokens.refresh_token,
scope: 'openid email profile'
});
}
Secure Token Generation
Generate secure JWTs with proper claims:
async function generateAccessToken(userId: string, email: string): Promise<string> {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const jti = generateJTI();
const jwt = await new SignJWT({
sub: userId,
email: email,
aud: 'mcp-server',
jti: jti,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600 // 1 hour
})
.setProtectedHeader({ alg: 'HS256' })
.sign(secret);
return jwt;
}
function generateRefreshToken(): string {
// Generate cryptographically secure refresh token
return crypto.randomBytes(32).toString('base64url');
}
User Consent Flow
Implement a clean consent interface:
// React component for consent page
export default function OAuthConfirm() {
const [oauthParams, setOauthParams] = useState(null);
const handleAuthorize = async () => {
// Grant consent
const response = await fetch('/api/oauth/consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_identifier: oauthParams.client_identifier,
scope: oauthParams.scope
})
});
if (response.ok) {
// Redirect back to authorization endpoint
const authorizeUrl = new URL('/api/oauth/authorize', window.location.origin);
Object.entries(oauthParams).forEach(([key, value]) => {
if (value && key !== 'timestamp') {
authorizeUrl.searchParams.set(key, value);
}
});
window.location.href = authorizeUrl.toString();
}
};
return (
<Card>
<CardHeader>
<CardTitle>Authorization Request</CardTitle>
<CardDescription>
An MCP client wants to access your account
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<Alert>
<Shield className="h-4 w-4" />
<AlertDescription>
This will allow the client to access your profile information
and perform actions on your behalf.
</AlertDescription>
</Alert>
<div className="flex gap-3">
<Button onClick={handleAuthorize}>
Authorize
</Button>
<Button variant="outline" onClick={() => window.close()}>
Cancel
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
Security Best Practices
1. Always Require PKCE
PKCE (Proof Key for Code Exchange) is mandatory for OAuth 2.1:
function validatePKCE(code_challenge: string, code_challenge_method: string) {
if (!code_challenge || !code_challenge_method) {
return { isValid: false, reason: 'PKCE parameters are required' };
}
if (code_challenge_method !== 'S256') {
return { isValid: false, reason: 'Only S256 challenge method is supported' };
}
return { isValid: true };
}
2. Implement Refresh Token Rotation
Rotate refresh tokens on each use for enhanced security:
async function handleRefreshTokenGrant(refresh_token: string) {
// Validate current refresh token
const session = await validateRefreshToken(refresh_token);
if (!session) {
return createOAuthErrorResponse('invalid_grant', 'Invalid refresh token');
}
// Generate new tokens
const newAccessToken = await generateAccessToken(session.user_id, session.email);
const newRefreshToken = generateRefreshToken();
// Rotate refresh token in database
await rotateRefreshToken(session.id, newRefreshToken);
return {
access_token: newAccessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: newRefreshToken
};
}
3. Validate Redirect URIs Strictly
Implement strict redirect URI validation:
function isValidMCPRedirectUri(redirect_uri: string): boolean {
// Allow localhost for development
if (redirect_uri.match(/^https?://(localhost|127.0.0.1):d+//)) {
return true;
}
// Allow HTTPS for production
if (redirect_uri.startsWith('https://')) {
return true;
}
// Allow IDE custom schemes
const allowedSchemes = ['vscode:', 'cursor:', 'jetbrains:', 'mcp:'];
if (allowedSchemes.some(scheme => redirect_uri.startsWith(scheme))) {
return true;
}
return false;
}
4. Implement Proper CORS
Configure CORS for OAuth endpoints:
function setCORSHeaders(res: ServerResponse) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
}
Integration with MCP Servers
Your MCP server can now authenticate requests using the issued tokens:
async function validateMCPRequest(request: any): Promise<AuthContext> {
const authHeader = request.headers?.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return { isAuthenticated: false, error: 'Missing authorization header' };
}
const token = authHeader.substring(7);
try {
const payload = await verifyJWT(token);
return {
isAuthenticated: true,
userId: payload.sub,
email: payload.email,
tokenJti: payload.jti
};
} catch (error) {
return { isAuthenticated: false, error: 'Invalid token' };
}
}
Testing Your OAuth Implementation
Test the complete flow:
1. Discovery: Verify well-known endpoints return correct metadata
2. Authorization: Test the authorization flow with various clients
3. Token Exchange: Confirm PKCE validation and token generation
4. Refresh: Test refresh token rotation
5. Resource Access: Verify token validation in MCP server
Production Considerations
Database Schema
Store OAuth sessions securely:
CREATE TABLE mcp_server_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
client_identifier TEXT NOT NULL,
refresh_token_hash TEXT NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_mcp_sessions_refresh_token ON mcp_server_sessions(refresh_token_hash);
CREATE INDEX idx_mcp_sessions_expires_at ON mcp_server_sessions(expires_at);
Environment Configuration
# OAuth Configuration
JWT_SECRET=your-jwt-secret-key
OAUTH_BASE_URL=https://your-domain.com
MCP_SERVER_AUDIENCE=mcp-server
# Token Lifetimes
ACCESS_TOKEN_EXPIRES=3600 # 1 hour
REFRESH_TOKEN_EXPIRES=7776000 # 90 days
Monitoring and Logging
Implement comprehensive logging:
function logOAuthRequest(endpoint: string, params: any, userAgent?: string) {
console.log('OAuth Request:', {
endpoint,
timestamp: new Date().toISOString(),
userAgent,
params: sanitizeParams(params)
});
}
function sanitizeParams(params: any) {
const { code_challenge, code_verifier, access_token, refresh_token, ...safe } = params;
return safe;
}
Conclusion
Implementing OAuth 2.1 in MCP servers provides enterprise-grade security while maintaining compatibility with AI clients. Key benefits include:
- Enhanced Security: PKCE, token rotation, and strict validation
- Scalability: Support for multiple clients and users
- Compliance: Meets enterprise authentication requirements
- Flexibility: Works with any OAuth 2.1 compatible client
By following this implementation guide, you'll have a production-ready OAuth system that secures your MCP server while providing a seamless experience for users and AI clients.
The OAuth implementation ensures your MCP server can safely operate in production environments, handling sensitive data and operations with the security controls that enterprises require.