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
- MCP Client to MCP Server: MCP request without token
- MCP Server to MCP Client: HTTP 401 + WWW-Authenticate header
- Note: Extract resource_metadata URL
- MCP Client to LumoAuth: GET Protected Resource Metadata
- LumoAuth to MCP Client: Return metadata (authorization_servers, scopes)
- Note: Parse metadata, extract AS URL
- MCP Client to LumoAuth: GET Authorization Server Metadata
- LumoAuth to MCP Client: AS metadata (endpoints, capabilities)
- Note: client_id = https URL of the client's metadata document
- LumoAuth to MCP Client: Fetches and validates the document at authorization time
- Note: Use existing client_id
- MCP Client to LumoAuth: POST /connect/register
- LumoAuth to MCP Client: Client credentials
- Note: Generate PKCE parameters (S256)
- Note: Include resource parameter
- MCP Client to User-Agent: Open browser with authorization URL
- User-Agent to LumoAuth: Authorization request + code_challenge + resource
- Note: Authenticate user
- LumoAuth to User-Agent: Redirect to callback with auth code
- User-Agent to MCP Client: Authorization code callback
- MCP Client to LumoAuth: Token request + code_verifier + resource
- LumoAuth to MCP Client: Access token (+ refresh token)
- MCP Client to MCP Server: MCP request with Bearer token
- MCP Server to MCP Client: MCP response
- 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.
}
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):
| Priority | Method | When to use |
|---|---|---|
| 1 | Client 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. |
| 2 | Pre-registered client | The client already has a client_id for this authorization server (registered under Applications, or handed to the client by the administrator). |
| 3 | Dynamic client registration (the client registers itself through the API) — no longer the specification's preferred method | The 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"
}'
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.
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).
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"
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"
}'
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:
- Fetch the JWKS (the list of public keys used to verify tokens) 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, and your client must be on that server's allow-list (if it has one) |
invalid_dpop_proof | The MCP server requires DPoP and the request carried no valid DPoP proof | Send 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 ifcode_challengeis missing; public clients include every dynamically registered or CIMD client withtoken_endpoint_auth_method: "none". Anycode_challenge_methodother thanS256is always rejected there. The MCP server's ownrequire_pkcepolicy is enforced twice. Up front, an/authorizeor PAR request naming the server's resource without acode_challengefails withinvalid_requestbefore any login or consent. Then, when the code is exchanged at the token endpoint, the exchange fails withinvalid_grantif the code was issued without a code challenge. - Resource Binding: Tokens are bound to the MCP server's Resource URI via the
resourceparameter andaudclaim. Withrequire_resource_paramon, omittingresourceis refused (invalid_target) when the client is allow-listed for the server or requests a scope from the server'sscopes_supportedvocabulary. - Scope Vocabulary: when the server defines
scopes_supported, requests for its resource may only ask for those scopes (plus OIDC identity scopes); anything else isinvalid_scope. See Scope vocabulary. - Step-up (
insufficient_scope): the challenge endpoint answers a valid token that lacks the required scopes with403andWWW-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_dpoponly gets sender-constrained tokens. Every grant for its resource must present a validDPoPproof (or redeem a key-bound refresh token) or fails withinvalid_dpop_proof. Its challenge uses theDPoPscheme withalgsand rejects plainBearertokens, and its Protected Resource Metadata carriesdpop_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.
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 name | Standard |
|---|---|
| Model Context Protocol (MCP) authorization | MCP Authorization specification |
| Protected resource metadata | RFC 9728 |
| Discovery document | RFC 8414 |
| Client ID metadata documents | OAuth Client ID Metadata Document (draft) |
| Dynamic client registration | RFC 7591 |
| Proof Key for Code Exchange (PKCE) | RFC 7636 |
| Resource indicators | RFC 8707 |
| Sender-constrained tokens (DPoP) | RFC 9449 |
| Token introspection | RFC 7662 |
| JWT access token profile | RFC 9068 |
| Bearer tokens | RFC 6750 |
See the standards reference for what each one covers and how LumoAuth uses it.