This comprehensive guide covers the OAuth 2.0 + PKCE implementation in the MCP Feature Reference Server, including architecture patterns, detailed flow analysis, and integration guidance.
Quick Start: If you're new to this codebase, start with Implementation Modes to understand the difference between internal (development) and external (production) auth modes.
- Architecture Overview
- Implementation Modes
- OAuth Flow Details
- Commercial Provider Integration
- Error Handling
- Testing & Troubleshooting
- Best Practices
This implementation uses the separate authorization server pattern as recommended by the MCP Authorization specification. The authorization server is architecturally separate from the MCP resource server, ensuring clean separation of concerns and enabling integration with commercial OAuth providers.
- Separation of Concerns: OAuth logic is isolated from MCP functionality
- Token-Based Security: All MCP requests require valid bearer tokens
- PKCE Protection: Prevents authorization code interception attacks
- Flexible Deployment: Supports both development and production configurations
The server supports two modes while maintaining the same architectural pattern:
In internal mode, OAuth endpoints run in the same process as the MCP server for convenience during development and exploration.
Configuration:
AUTH_MODE=internal # or leave unset (default)
# Server runs on port 3232
# OAuth and MCP endpoints share the same portArchitecture:
┌─────────────┐ ┌───────────────────────────────────────┐
│ │ OAuth │ Single Process (port 3232) │
│ MCP Client │────────>│ ┌──────────────┐ ┌──────────────┐ │
│ │ │ │ Auth Module │ │ MCP Module │ │
│ │<────────│ │ │ │ │ │
│ │ token │ │Issues tokens │────│ Serves MCP │ │
│ │ │ └──────────────┘ └──────────────┘ │
│ │────────>│ MCP requests with token │
└─────────────┘ └───────────────────────────────────────┘
In external mode, the authorization server runs as a separate process, following production best practices.
Configuration:
AUTH_MODE=external
AUTH_SERVER_URL=http://localhost:3001 # or commercial provider URL
# Auth server on port 3001
# MCP server on port 3232Architecture:
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ │ OAuth │ │ Token │ │
│ MCP Client │────────>│ Auth Server │<────────│ MCP Server │
│ │ │ (port 3001) │validate │ (port 3232) │
│ │<────────│ │ │ │
│ │ token │ Issues tokens │ │ Serves MCP │
│ │ └──────────────────┘ │ resources │
│ │────────────────────────────────────> │ │
│ │ MCP requests with token │ │
└─────────────┘ └─────────────────┘
| Aspect | Internal Mode | External Mode |
|---|---|---|
| Process Architecture | Single process | Multiple processes |
| Port Usage | One port (3232) | Two ports (3001 + 3232) |
| OAuth Endpoints | Same port as MCP | Different port/server |
| Token Validation | In-process call | HTTP introspection |
| Production Ready | Development only | ✅ Production recommended |
| Commercial Auth Providers | Not applicable | ✅ Supported |
The implementation follows OAuth 2.0 with PKCE (RFC 7636) for secure authorization.
Purpose: Register the application with the OAuth server (one-time setup)
POST /register
Content-Type: application/json
{
"client_name": "My MCP Client",
"redirect_uris": ["http://localhost:3000/callback"]
}Response:
{
"client_id": "abc123",
"client_secret": "secret456",
"client_id_issued_at": 1234567890,
"client_secret_expires_at": 0
}Storage: Redis key auth:client:{clientId} (30-day expiry)
Purpose: Initiate OAuth flow with PKCE
GET /authorize?
client_id=abc123&
redirect_uri=http://localhost:3000/callback&
code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
code_challenge_method=S256&
state=xyz789PKCE Security:
- Client generates random
code_verifier(43-128 characters) - Sends SHA256 hash as
code_challenge - Must provide original verifier during token exchange
Storage: Redis key auth:pending:{authCode} (10-minute expiry)
The auth server authenticates the user and obtains consent:
- Shows authorization page
- User authenticates (via upstream IDP in production)
- Issues authorization code
- Redirects to client's
redirect_uriwith code
Purpose: Exchange authorization code for tokens
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
client_id=abc123&
client_secret=secret456&
code=auth_code_here&
redirect_uri=http://localhost:3000/callback&
code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXkPKCE Validation:
// Server verifies: SHA256(code_verifier) === stored code_challenge
const challenge = base64url(sha256(code_verifier));
if (challenge !== stored_code_challenge) {
throw new Error('Invalid PKCE verifier');
}Response:
{
"access_token": "eyJhbGc...",
"refresh_token": "refresh_xyz",
"token_type": "Bearer",
"expires_in": 604800
}Purpose: Access MCP resources with bearer token
POST /mcp
Authorization: Bearer eyJhbGc...
Mcp-Session-Id: session-123
{
"jsonrpc": "2.0",
"method": "tools/list",
"id": 1
}Token Validation Process:
- MCP server extracts bearer token
- Calls auth server's
/introspectendpoint - Validates token is active and not expired
- Extracts user ID from
subclaim - Processes MCP request with user context
Purpose: Obtain new access token when current expires
POST /token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&
refresh_token=refresh_xyz&
client_id=abc123&
client_secret=secret456Response: New access token (and optionally new refresh token)
The demo auth server should be replaced with a commercial OAuth provider in production.
- Auth0 - Full OAuth 2.0 + OIDC support
- Okta - Enterprise identity management
- Azure AD/Microsoft Entra - Microsoft identity platform
- AWS Cognito - AWS managed authentication
- Google Identity Platform - Google OAuth
- GitHub OAuth - Developer-friendly OAuth
- Any RFC 7662-compliant OAuth provider
In your OAuth provider's dashboard:
- Create new OAuth application/client
- Set redirect URIs (e.g.,
http://localhost:3000/callback) - Enable token introspection endpoint (if required)
- Note client ID and secret
# .env file
AUTH_MODE=external
AUTH_SERVER_URL=https://your-tenant.auth0.com
# For Okta: https://your-domain.okta.com
# For Azure: https://login.microsoftonline.com/your-tenantMost providers follow RFC 7662, but some require authentication. Edit src/interfaces/auth-validator.ts:
// In ExternalTokenValidator.introspect() method
const response = await fetch(`${this.authServerUrl}/oauth/introspect`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
// Some providers require authentication
'Authorization': `Basic ${Buffer.from('client_id:client_secret').toString('base64')}`
},
body: `token=${encodeURIComponent(token)}`
});Auth0:
AUTH_SERVER_URL=https://your-tenant.auth0.com
# Introspection endpoint: /oauth/token_infoOkta:
AUTH_SERVER_URL=https://your-domain.okta.com/oauth2/default
# Introspection endpoint: /v1/introspectAzure AD:
AUTH_SERVER_URL=https://login.microsoftonline.com/your-tenant-id
# May require additional configuration for introspection| Error | Cause | Solution |
|---|---|---|
invalid_client |
Wrong client credentials | Verify client_id and client_secret |
invalid_grant |
Expired/invalid auth code | Ensure code is used within 10 minutes |
invalid_request |
Missing required parameters | Check all OAuth parameters are provided |
unauthorized_client |
Client not authorized for grant type | Verify client registration settings |
invalid_token |
Token expired or revoked | Refresh token or re-authenticate |
// Token expired
if (result.exp && result.exp < Date.now() / 1000) {
throw new InvalidTokenError('Token has expired');
}
// Token not active
if (!result.active) {
throw new InvalidTokenError('Token is not active');
}
// Wrong audience
if (result.aud !== expectedAudience) {
throw new InvalidTokenError('Token audience mismatch');
}{
"error": "invalid_token",
"error_description": "The access token expired",
"error_uri": "https://tools.ietf.org/html/rfc6750#section-3.1"
}# Test internal mode
npm run dev:internal
npx @modelcontextprotocol/inspector
# Connect to http://localhost:3232/mcp
# Test external mode
npm run dev:external
npx @modelcontextprotocol/inspector
# Connect to http://localhost:3232/mcp
# Run automated tests
npm test -- --testNamePattern="OAuth"
npm run test:e2eIssue: "Cannot connect to auth server"
- Check
AUTH_MODEandAUTH_SERVER_URLin.env - Verify auth server is running (external mode)
- Check network connectivity to auth server
Issue: "Token validation failed"
- Verify tokens haven't expired (7-day default)
- Check Redis connection if using Redis storage
- Ensure auth server introspection endpoint is accessible
Issue: "PKCE validation failed"
- Ensure code_verifier is 43-128 characters
- Verify SHA256 hashing is correct
- Check code_challenge_method is 'S256'
Issue: "Session not found"
- Verify Redis is running if configured
- Check session hasn't expired
- Ensure same user is accessing the session
Enable detailed logging for troubleshooting:
# Enable debug logs
DEBUG=* npm run dev:internal
# Check auth flow metadata
curl -v http://localhost:3232/.well-known/oauth-authorization-server
# Test introspection (external mode only, on auth server)
# Note: In internal mode, introspection is handled internally
curl -X POST http://localhost:3001/introspect \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "token=YOUR_TOKEN"# Monitor Redis for token operations (when Redis is configured)
redis-cli MONITOR | grep auth:
# Check active sessions
redis-cli KEYS "auth:installation:*"
# Check refresh tokens
redis-cli KEYS "auth:refresh:*"- Always use HTTPS in production - Prevents token interception
- Implement PKCE for all flows - Required by OAuth 2.1
- Validate token audience - Prevents token substitution attacks
- Use short token expiries - Limits exposure window
- Rotate refresh tokens - Issue new refresh token on use
- Log authentication events - For security auditing
- Rate limit auth endpoints - Prevent brute force attacks
- Cache introspection results - Reduce auth server load
- Use connection pooling - For Redis and HTTP connections
- Implement token refresh ahead of expiry - Prevent interruptions
- Batch token validations - When processing multiple requests
- Monitor auth server latency - Set appropriate timeouts
- Use internal mode for development - Faster iteration
- Test with external mode before production - Catch integration issues
- Implement comprehensive error handling - Better debugging
- Document your OAuth configuration - For team members
- Use environment variables - Never hardcode credentials
- Use commercial OAuth provider - Better security and reliability
- Enable Redis for sessions - Support multiple instances
- Implement health checks - Monitor auth availability
- Set up monitoring and alerting - Track auth failures
- Plan for token migration - When changing providers
- Document disaster recovery - Auth server failure procedures
When Redis is configured, the following data is stored with automatic expiry:
| Data Type | Redis Key Pattern | Default Expiry | Purpose |
|---|---|---|---|
| OAuth flow state | auth:pending:{code} |
10 minutes | Temporary auth state |
| Token exchange | auth:exch:{code} |
10 minutes | Prevent replay attacks |
| User sessions | auth:installation:{token} |
7 days | Active sessions |
| Refresh tokens | auth:refresh:{token} |
7 days | Token refresh |
| Client credentials | auth:client:{id} |
30 days | App registration |
Note: When Redis is not configured (in-memory storage), all data is lost on server restart.
Expired data is automatically cleaned up by Redis TTL. For manual cleanup:
# Remove all auth data (CAUTION: will log out all users)
redis-cli --scan --pattern "auth:*" | xargs redis-cli DEL
# Remove specific user session
redis-cli DEL "auth:installation:ACCESS_TOKEN"