Skip to main content

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:

PriorityMethodWhen to use
1Pre-registered clientClient already has credentials for this authorization server
2Client ID Metadata DocumentsAS supports client_id_metadata_document_supported — use an HTTPS URL as client_id
3Dynamic Client RegistrationAS 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"
}'
Public client authentication

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"
Required parameters

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"
}'
Token in every request

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:

  1. Fetch the JWKS from LumoAuth's jwks_uri
  2. Verify the JWT signature
  3. Check exp (expiration), iss (issuer), and aud (audience)
  4. Verify aud matches your server's Resource URI
Audience validation is critical

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

StatusWhenHeader
401 UnauthorizedNo token, expired token, or invalid tokenWWW-Authenticate: Bearer resource_metadata="..."
403 ForbiddenToken valid but insufficient scopesWWW-Authenticate: Bearer error="insufficient_scope", scope="...", resource_metadata="..."

Common Token Request Errors

ErrorDescriptionSolution
invalid_requestMissing required parameter: client_idAlways include client_id in token requests, even for public clients with token_endpoint_auth_method: "none"
invalid_grantInvalid or expired authorization codeAuthorization codes are single-use and expire after 5 minutes. Ensure you exchange them immediately.
invalid_grantPKCE validation failedVerify your code_verifier matches the code_challenge sent in the authorization request
invalid_clientClient authentication failedFor confidential clients, verify your client_secret is correct. For public clients, ensure client_id is included.
invalid_targetInvalid or unauthorized resourceThe 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 resource parameter (RFC 8707) and aud claim.
  • 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.