MCP Authorization Flow
End-to-end walk-through of the OAuth 2.1 authorization flow for MCP servers — from the unauthenticated request that triggers discovery, through client registration and user authorization, to the Bearer token the MCP client sends with every call. Model Context Protocol (MCP) is a protocol for LLM hosts (Claude, ChatGPT, Cursor) to discover and call external tools; LumoAuth's role here is the OAuth 2.1 authorization server.
Complete flow diagram
Step-by-step implementation
Step 1: Trigger the authorization challenge
When an MCP client tries to connect to a protected MCP server without a token, the server returns 401 Unauthorized with a WWW-Authenticate header that contains the Protected Resource Metadata URL:
# MCP client sends request without authentication
curl -v https://mcp.example.com/mcp
# Server responds:
# HTTP/1.1 401 Unauthorized
# WWW-Authenticate: Bearer resource_metadata="https://app.lumoauth.dev/orgs/acme-corp/api/v1/.well-known/oauth-protected-resource/mcp/mcp_abc123",
# scope="mcp:read mcp:write"
Step 2: Discover the authorization server
The MCP client fetches the Protected Resource Metadata to find which authorization server to use:
curl https://app.lumoauth.dev/orgs/acme-corp/api/v1/.well-known/oauth-protected-resource/mcp/mcp_abc123
{
"resource": "https://mcp.example.com",
"authorization_servers": [
"https://app.lumoauth.dev/orgs/acme-corp/api/v1/.well-known/oauth-authorization-server"
],
"bearer_methods_supported": ["header"],
"scopes_supported": ["mcp:read", "mcp:write"]
}
Then fetch the Authorization Server Metadata:
curl https://app.lumoauth.dev/orgs/acme-corp/api/v1/.well-known/oauth-authorization-server
{
"issuer": "https://app.lumoauth.dev/orgs/acme-corp/api/v1",
"authorization_endpoint": "https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/authorize",
"token_endpoint": "https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/token",
"introspection_endpoint": "https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/introspect",
"registration_endpoint": "https://app.lumoauth.dev/orgs/acme-corp/api/v1/connect/register",
"code_challenge_methods_supported": ["S256"],
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
"client_id_metadata_document_supported": true
}
Step 3: Client registration
MCP supports three client-registration approaches. Clients SHOULD follow this priority order:
| Priority | Method | When to use |
|---|---|---|
| 1 | Pre-registered client | Client already has credentials for this authorization server |
| 2 | Client ID Metadata Documents | AS supports client_id_metadata_document_supported — use an HTTPS URL as client_id |
| 3 | Dynamic Client Registration | AS provides registration_endpoint |
curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/connect/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "My MCP Client",
"redirect_uris": ["http://127.0.0.1:3000/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}'
token_endpoint_auth_method: "none" creates a public client that doesn't require a client secret. The client_id returned in the registration response must still be sent in every token request. Public clients use PKCE for security instead of a shared secret.
Step 4: Authorization request
The MCP client starts the OAuth 2.1 authorization-code flow with PKCE and a resource parameter:
# Generate PKCE parameters
CODE_VERIFIER=$(openssl rand -base64 32 | tr -d '=/+' | head -c 43)
CODE_CHALLENGE=$(echo -n "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
# Build authorization URL
AUTH_URL="https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/authorize?\
response_type=code&\
client_id=CLIENT_ID&\
redirect_uri=http://127.0.0.1:3000/callback&\
scope=mcp:read mcp:write&\
resource=https://mcp.example.com&\
code_challenge=$CODE_CHALLENGE&\
code_challenge_method=S256&\
state=RANDOM_STATE"
# Open in browser for user authentication
echo "Open in browser: $AUTH_URL"
The resource parameter must match a registered MCP server URI in your organization.
Use the exact URI configured during server registration.
Step 5: Token request
Exchange the authorization code for an access token:
curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=AUTHORIZATION_CODE" \
-d "redirect_uri=http://127.0.0.1:3000/callback" \
-d "client_id=CLIENT_ID" \
-d "code_verifier=$CODE_VERIFIER" \
-d "resource=https://mcp.example.com"
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...",
"scope": "mcp:read mcp:write"
}
Step 6: Access the MCP server
Include the access token in the Authorization header for all MCP requests:
curl -X POST https://mcp.example.com/mcp \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..." \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
}'
Per the MCP spec, authorization MUST be included in every HTTP request from client to server, even within the same logical session. Tokens MUST NOT be included in the URI query string.
Token validation
Your MCP server MUST validate access tokens before processing requests. LumoAuth supports two validation methods:
Option A: Token introspection
curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/introspect \
-H "Content-Type: application/x-www-form-urlencoded" \
-u "CLIENT_ID:CLIENT_SECRET" \
-d "token=eyJhbGciOiJSUzI1NiIs..."
{
"active": true,
"scope": "mcp:read mcp:write",
"client_id": "client_abc123",
"token_type": "Bearer",
"exp": 1739190000,
"aud": "https://mcp.example.com",
"sub": "user-uuid"
}
Option B: JWT verification
For JWT access tokens (LumoAuth's default, per RFC 9068 — JWT Profile for OAuth 2.0 Access Tokens, which standardizes the claim set for JWT-formatted access tokens), validate locally using the organization's public keys:
- Fetch the JWKS from LumoAuth's
jwks_uri - Verify the JWT signature
- Check
exp(expiration),iss(issuer), andaud(audience) - Verify
audmatches your server's Resource URI
MCP servers MUST validate that the aud claim matches their Resource URI. Without this check, tokens issued for other services could be replayed against your MCP server, enabling the confused deputy problem.
Error handling
| Status | When | Header |
|---|---|---|
401 Unauthorized | No token, expired token, or invalid token | WWW-Authenticate: Bearer resource_metadata="..." |
403 Forbidden | Token valid but insufficient scopes | WWW-Authenticate: Bearer error="insufficient_scope", scope="...", resource_metadata="..." |
Common Token Request Errors
| Error | Description | Solution |
|---|---|---|
invalid_request | Missing required parameter: client_id | Always include client_id in token requests, even for public clients with token_endpoint_auth_method: "none" |
invalid_grant | Invalid or expired authorization code | Authorization codes are single-use and expire after 5 minutes. Ensure you exchange them immediately. |
invalid_grant | PKCE validation failed | Verify your code_verifier matches the code_challenge sent in the authorization request |
invalid_client | Client authentication failed | For confidential clients, verify your client_secret is correct. For public clients, ensure client_id is included. |
invalid_target | Invalid or unauthorized resource | The resource parameter must match a registered MCP server Resource URI |
Security Considerations
- PKCE Required: MCP clients MUST use PKCE with
S256. LumoAuth rejects authorization requests without a valid code challenge. - Resource Binding: Tokens are bound to the MCP server's Resource URI via the
resourceparameter (RFC 8707) andaudclaim. - No Token Passthrough: MCP servers MUST NOT forward client tokens to upstream APIs. Each service-to-service call requires its own token.
- Short-lived Tokens: LumoAuth issues short-lived access tokens (configurable per MCP server). Use refresh tokens for long-lived sessions.
- HTTPS Only: All authorization server endpoints use HTTPS. Resource URIs should use HTTPS in production.