Skip to main content

MCP Authorization Flow

End-to-end walk-through of the OAuth 2.1 authorization flow for MCP (Model Context Protocol, the protocol that AI applications use to call external tools) servers. It runs from the unauthenticated request that triggers discovery, through client registration and user authorization, to the Bearer token that the MCP client sends with every call. LumoAuth's role here is the OAuth 2.1 authorization server (see the MCP authorization overview for roles and standards).

Complete flow diagram​

25 / 25
  1. MCP Client to MCP Server: MCP request without token
  2. MCP Server to MCP Client: HTTP 401 + WWW-Authenticate header
  3. Note: Extract resource_metadata URL
  4. MCP Client to LumoAuth: GET Protected Resource Metadata
  5. LumoAuth to MCP Client: Return metadata (authorization_servers, scopes)
  6. Note: Parse metadata, extract AS URL
  7. MCP Client to LumoAuth: GET Authorization Server Metadata
  8. LumoAuth to MCP Client: AS metadata (endpoints, capabilities)
  9. Note: client_id = https URL of the client's metadata document
  10. LumoAuth to MCP Client: Fetches and validates the document at authorization time
  11. Note: Use existing client_id
  12. MCP Client to LumoAuth: POST /connect/register
  13. LumoAuth to MCP Client: Client credentials
  14. Note: Generate PKCE parameters (S256)
  15. Note: Include resource parameter
  16. MCP Client to User-Agent: Open browser with authorization URL
  17. User-Agent to LumoAuth: Authorization request + code_challenge + resource
  18. Note: Authenticate user
  19. LumoAuth to User-Agent: Redirect to callback with auth code
  20. User-Agent to MCP Client: Authorization code callback
  21. MCP Client to LumoAuth: Token request + code_verifier + resource
  22. LumoAuth to MCP Client: Access token (+ refresh token)
  23. MCP Client to MCP Server: MCP request with Bearer token
  24. MCP Server to MCP Client: MCP response
  25. Note: MCP communication continues

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 (the discovery document that LumoAuth publishes for each registered MCP server) 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",
"jwks_uri": "https://app.lumoauth.dev/orgs/acme-corp/api/v1/.well-known/jwks.json",
"code_challenge_methods_supported": ["S256"],
"grant_types_supported": [
"authorization_code",
"refresh_token",
"client_credentials",
"urn:openid:params:grant-type:ciba",
"urn:ietf:params:oauth:grant-type:device_code"
]
// Abbreviated. The live document also returns revocation_endpoint,
// device_authorization_endpoint, pushed_authorization_request_endpoint,
// scopes_supported, response_types_supported, token_endpoint_auth_methods_supported, etc.
}
Find the JWKS via this document

Note that authorization_servers[0] in the Protected Resource Metadata above is the URL of this Authorization Server Metadata document, not a bare base URL. To locate the JWKS, fetch this document and read its jwks_uri field — do not assume a fixed /.well-known/jwks.json path relative to the metadata URL.

Step 3: Client identification​

LumoAuth supports three ways for a client to identify itself. Clients SHOULD try them in this order (the MCP authorization specification, revision 2025-11-25, recommends Client ID Metadata Documents over dynamic client registration):

PriorityMethodWhen to use
1Client ID Metadata Documents (CIMD; the client identifies itself with the URL of a metadata file that it publishes)The AS advertises client_id_metadata_document_supported: true (on by default on every plan) — use your metadata-document URL as the client_id; no registration call. The default for Claude and the method that the MCP specification recommends.
2Pre-registered clientThe client already has a client_id for this authorization server (registered under Applications, or handed to the client by the administrator).
3Dynamic client registration (the client registers itself through the API) — no longer the specification's preferred methodThe AS provides registration_endpoint. Still supported for clients that have not moved to CIMD (ChatGPT's connectors use it).

The server's page in the Organization Portal shows ready-made setup snippets for Claude (CIMD, with the DCR variant as legacy), ChatGPT, a generic mcp.json client and a manual curl flow. Each snippet uses your organization's real endpoints.

The DCR request, for clients that still need it:

curl -X POST https://app.lumoauth.dev/orgs/acme-corp/api/v1/connect/register \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <INITIAL_ACCESS_TOKEN>" \
-d '{
"client_name": "My MCP Client",
"application_type": "native",
"redirect_uris": ["http://127.0.0.1:3000/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}'
application_type (SEP-837)

Loopback (http://127.0.0.1:*) or custom-scheme redirect URIs require application_type: "native". The default web application type only accepts https redirect URIs.

Initial Access Token required by default

By default, dynamic registration requires an Initial Access Token (IAT): an anonymous POST /connect/register with no Authorization header returns an error. Pass the IAT as Authorization: Bearer <INITIAL_ACCESS_TOKEN> (shown above), or have an admin issue API-key credentials with the admin:clients:register scope. An organization can opt into anonymous DCR by setting require_initial_access_token to false in its authentication settings (PATCH /orgs/{orgId}/api/v1/admin/settings/auth).

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. PKCE is a one-time secret that protects the sign-in code; the resource parameter is the resource indicator that pins the token to one MCP server:

# 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

Send the resource parameter set to the exact Resource URI configured during server registration, and repeat it on the token request. The authorization endpoint only checks that resource is a well-formed URI allowed for your client. Matching it against registered MCP servers happens when the code is exchanged at the token endpoint; that is also where the request is rejected with invalid_target when your client is not on that server's allow-list.

Step 5: Token request​

Exchange the authorization code for an access token. If the MCP server was registered with Require DPoP (DPoP ties a token to the client's key so a stolen copy cannot be replayed), this request must also carry a DPoP proof header; without it the token endpoint answers invalid_dpop_proof. The response is then token_type: "DPoP" with the key thumbprint in the token's cnf.jkt:

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; they follow the standard JWT access token profile, which fixes the claim set of a JWT-formatted access token), validate locally using the organization's public keys:

  1. Fetch the JWKS (the list of public keys used to verify tokens) 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, and your client must be on that server's allow-list (if it has one)
invalid_dpop_proofThe MCP server requires DPoP and the request carried no valid DPoP proofSend a DPoP proof JWT in the DPoP header, or ask the administrator to turn Require DPoP off for that server

Security Considerations​

  • PKCE Required: MCP clients MUST use PKCE with S256. Public clients and clients flagged PKCE required are rejected at the authorization endpoint if code_challenge is missing; public clients include every dynamically registered or CIMD client with token_endpoint_auth_method: "none". Any code_challenge_method other than S256 is always rejected there. The MCP server's own require_pkce policy is enforced twice. Up front, an /authorize or PAR request naming the server's resource without a code_challenge fails with invalid_request before any login or consent. Then, when the code is exchanged at the token endpoint, the exchange fails with invalid_grant if the code was issued without a code challenge.
  • Resource Binding: Tokens are bound to the MCP server's Resource URI via the resource parameter and aud claim. With require_resource_param on, omitting resource is refused (invalid_target) when the client is allow-listed for the server or requests a scope from the server's scopes_supported vocabulary.
  • Scope Vocabulary: when the server defines scopes_supported, requests for its resource may only ask for those scopes (plus OIDC identity scopes); anything else is invalid_scope. See Scope vocabulary.
  • Step-up (insufficient_scope): the challenge endpoint answers a valid token that lacks the required scopes with 403 and WWW-Authenticate: Bearer error="insufficient_scope", scope="<missing scopes>" (the standard bearer-token challenge). The client then re-authorizes for exactly the missing scopes. See Step-up authorization.
  • No Token Passthrough: MCP servers MUST NOT forward client tokens to upstream APIs. Each service-to-service call requires its own token.
  • DPoP (per server): a server registered with require_dpop only gets sender-constrained tokens. Every grant for its resource must present a valid DPoP proof (or redeem a key-bound refresh token) or fails with invalid_dpop_proof. Its challenge uses the DPoP scheme with algs and rejects plain Bearer tokens, and its Protected Resource Metadata carries dpop_bound_access_tokens_required. See DPoP requirement.
  • Short-lived Tokens: LumoAuth issues short-lived access tokens (configurable per MCP server; the portal recommends 3600 s or less and the server's posture check warns above it). Use refresh tokens for long-lived sessions.
  • HTTPS Only: All authorization server endpoints use HTTPS. Resource URIs should use HTTPS in production.
Per-tool authorization

The flow above authorizes access at the server boundary. To control access to individual tools for an agent, call the per-tool endpoint POST /orgs/{orgId}/api/v1/agents/me/mcp/authorize — see Per-tool authorization.

Standards used on this page​

Plain nameStandard
Model Context Protocol (MCP) authorizationMCP Authorization specification
Protected resource metadataRFC 9728
Discovery documentRFC 8414
Client ID metadata documentsOAuth Client ID Metadata Document (draft)
Dynamic client registrationRFC 7591
Proof Key for Code Exchange (PKCE)RFC 7636
Resource indicatorsRFC 8707
Sender-constrained tokens (DPoP)RFC 9449
Token introspectionRFC 7662
JWT access token profileRFC 9068
Bearer tokensRFC 6750

See the standards reference for what each one covers and how LumoAuth uses it.