Repository navigation
Regression (OAuth flow): Calling token endpoint does not honor "token_endpoint_auth_methods_supported" #726
Description
Describe the bug
The OAuth flow always chooses client_secret_post regardless of the value declared in token_endpoint_auth_methods_supported and the presence of client_secret
To Reproduce
Commence OAuth flow with any authorization server that issues a client_id and client_secret via the client registration endpoint (DCR). The call to the token endpoint will default to client_secret_post regardless of the token_endpoint_auth_methods_supported value returned in "/.well-known/oauth-authorization-server" endpoint.
Here's a simple Flask based dummy OAuth server that replicates the issue (fails with npx @modelcontextprotocol/inspector@0.16.4, passes with npx @modelcontextprotocol/inspector@0.16.3:
"""
Simple dummy OAuth authorization server for testing purposes.
Provides basic OAuth 2.0 endpoints with minimal validation.
"""
import base64
import json
import logging
from datetime import datetime, timedelta
from typing import Dict, Any, Optional, Tuple
from urllib.parse import urlencode, parse_qs
from flask import Flask, request, jsonify, redirect, make_response
logger = logging.getLogger(__name__)
app = Flask(__name__)
# CORS configuration
@app.after_request
def after_request(response):
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Access-Control-Allow-Headers'] = 'Content-Type,Authorization,Accept'
response.headers['Access-Control-Allow-Methods'] = 'GET,POST,OPTIONS'
response.headers['Access-Control-Allow-Credentials'] = 'true'
return response
# Dummy client credentials (hardcoded for testing)
DUMMY_CLIENT_ID = "dummy_client_12345"
DUMMY_CLIENT_SECRET = "dummy_secret_abcdef123456"
def parse_client_credentials(auth_header: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]:
"""
Parse client credentials from Authorization header (Basic auth) or request body.
Returns tuple of (client_id, client_secret) or (None, None) if not found.
"""
client_id = None
client_secret = None
# Try Authorization header first (Basic auth)
if auth_header and auth_header.startswith('Basic '):
try:
encoded_credentials = auth_header[6:] # Remove 'Basic ' prefix
decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')
client_id, client_secret = decoded_credentials.split(':', 1)
except Exception as e:
logger.warning(f"Failed to parse Basic auth header: {e}")
# Fall back to request body if not in header
if not client_id:
content_type = request.headers.get('Content-Type', '')
if 'application/x-www-form-urlencoded' in content_type:
client_id = request.form.get('client_id')
client_secret = request.form.get('client_secret')
elif 'application/json' in content_type:
json_data = request.get_json() if request.is_json else None
if json_data:
client_id = json_data.get('client_id')
client_secret = json_data.get('client_secret')
return client_id, client_secret
@app.route('/register', methods=['POST'])
def register():
"""
Client registration endpoint.
Returns consistent dummy client credentials.
"""
try:
data = request.get_json() or {}
# Return the dummy client configuration
dummy_config = {
"client_id": DUMMY_CLIENT_ID,
"client_secret": DUMMY_CLIENT_SECRET,
"client_id_issued_at": int(datetime.utcnow().timestamp()),
"client_secret_expires_at": 0, # 0 = never expires (per spec)
"redirect_uris": ["http://localhost:3000/callback", "http://localhost:8080/callback"],
"token_endpoint_auth_method": "client_secret_basic",
"grant_types": ["authorization_code"],
"response_types": ["code"],
"client_name": "Dummy OAuth Client",
"client_uri": "http://localhost:3000",
"scope": "read write admin",
}
print(f"[REGISTER] Dummy client registered: {DUMMY_CLIENT_ID}")
print(f"[REGISTER] Client data: {json.dumps(dummy_config, indent=2)}")
return jsonify(dummy_config)
except Exception as e:
logger.error(f"Registration error: {e}")
return jsonify({'error': 'invalid_request', 'error_description': str(e)}), 400
@app.route('/.well-known/oauth-authorization-server', methods=['GET'])
def well_known_oauth():
"""
OAuth 2.0 Authorization Server Metadata endpoint.
Returns server capabilities and endpoint URLs.
"""
base_url = request.host_url.rstrip('/')
metadata = {
'issuer': base_url,
'authorization_endpoint': f'{base_url}/authorize',
'token_endpoint': f'{base_url}/token',
'registration_endpoint': f'{base_url}/register',
'response_types_supported': ['code'],
'grant_types_supported': ['authorization_code'],
'token_endpoint_auth_methods_supported': ['client_secret_basic'],
'scopes_supported': ['read', 'write', 'admin'],
'code_challenge_methods_supported': ['S256', 'plain']
}
print(f"[WELL-KNOWN] Served metadata for issuer: {base_url}")
return jsonify(metadata)
@app.route('/authorize', methods=['GET', 'POST'])
def authorize():
"""
Authorization endpoint.
Always returns successful authorization (dummy behavior).
"""
try:
# Extract parameters
client_id = request.args.get('client_id') or request.form.get('client_id')
redirect_uri = request.args.get('redirect_uri') or request.form.get('redirect_uri')
response_type = request.args.get('response_type') or request.form.get('response_type', 'code')
scope = request.args.get('scope') or request.form.get('scope', 'read')
state = request.args.get('state') or request.form.get('state')
code_challenge = request.args.get('code_challenge') or request.form.get('code_challenge')
code_challenge_method = request.args.get('code_challenge_method') or request.form.get('code_challenge_method')
print(f"[AUTHORIZE] Authorization request received:")
print(f"[AUTHORIZE] client_id: {client_id}")
print(f"[AUTHORIZE] redirect_uri: {redirect_uri}")
print(f"[AUTHORIZE] response_type: {response_type}")
print(f"[AUTHORIZE] scope: {scope}")
print(f"[AUTHORIZE] state: {state}")
print(f"[AUTHORIZE] code_challenge: {code_challenge}")
print(f"[AUTHORIZE] code_challenge_method: {code_challenge_method}")
if not client_id or not redirect_uri:
return jsonify({'error': 'invalid_request', 'error_description': 'Missing required parameters'}), 400
# Only support authorization code flow
if response_type != 'code':
return jsonify({'error': 'unsupported_response_type'}), 400
# Generate dummy authorization code (always the same for simplicity)
auth_code = "dummy_auth_code_12345"
# Build redirect URL
params = {'code': auth_code}
if state:
params['state'] = state
redirect_url = f"{redirect_uri}?{urlencode(params)}"
print(f"[AUTHORIZE] Authorization granted! Redirecting to: {redirect_url}")
return redirect(redirect_url)
except Exception as e:
logger.error(f"Authorization error: {e}")
return jsonify({'error': 'server_error', 'error_description': str(e)}), 500
@app.route('/token', methods=['POST'])
def token():
"""
Token endpoint.
Prints all received parameters and client credentials.
"""
try:
# Parse client credentials
auth_header = request.headers.get('Authorization')
client_id, client_secret = parse_client_credentials(auth_header)
# Simple validation - just check if it matches our dummy credentials
if client_id != DUMMY_CLIENT_ID or client_secret != DUMMY_CLIENT_SECRET:
print(f"[TOKEN] Invalid client credentials: {client_id}")
return jsonify({'error': 'invalid client credentials'}), 401
# Parse request data based on content type
content_type = request.headers.get('Content-Type', '')
request_data = {}
if 'application/x-www-form-urlencoded' in content_type:
request_data = dict(request.form)
print(f"[TOKEN] Processing application/x-www-form-urlencoded request")
elif 'application/json' in content_type:
try:
request_data = request.get_json() or {}
print(f"[TOKEN] Processing application/json request")
except Exception as e:
print(f"[TOKEN] Failed to parse JSON: {e}")
request_data = {}
else:
print(f"[TOKEN] Unsupported content type: {content_type}")
return jsonify({'error': 'invalid_request', 'error_description': 'Unsupported content type'}), 400
print(f"\n[TOKEN] Token request received:")
print(f"[TOKEN] Content-Type: {content_type}")
print(f"[TOKEN] Authorization header: {auth_header}")
print(f"[TOKEN] Parsed client_id: {client_id}")
print(f"[TOKEN] Parsed client_secret: {client_secret}")
print(f"[TOKEN] Request data: {json.dumps(request_data, indent=2)}")
print(f"[TOKEN] All headers: {dict(request.headers)}")
# Extract grant type and other parameters
grant_type = request_data.get('grant_type')
if grant_type != 'authorization_code':
print(f"[TOKEN] Unsupported grant type: {grant_type}")
return jsonify({'error': 'unsupported_grant_type'}), 400
code = request_data.get('code')
redirect_uri = request_data.get('redirect_uri')
code_verifier = request_data.get('code_verifier')
print(f"[TOKEN] Authorization code flow:")
print(f"[TOKEN] code: {code}")
print(f"[TOKEN] redirect_uri: {redirect_uri}")
print(f"[TOKEN] code_verifier: {code_verifier}")
# Always accept any authorization code (dummy server)
print(f"[TOKEN] Accepting authorization code: {code}")
# Generate dummy tokens (consistent for testing)
access_token = "dummy_access_token_12345"
refresh_token = "dummy_refresh_token_12345"
token_response = {
'access_token': access_token,
'token_type': 'Bearer',
'expires_in': 3600,
'refresh_token': refresh_token,
'scope': 'read write'
}
print(f"[TOKEN] Issuing tokens: {json.dumps(token_response, indent=2)}")
logger.info(f"Token issued for client {client_id}")
return jsonify(token_response)
except Exception as e:
logger.error(f"Token endpoint error: {e}")
print(f"[TOKEN] Error: {e}")
return jsonify({'error': 'server_error', 'error_description': str(e)}), 500
if __name__ == '__main__':
logging.basicConfig(level=logging.INFO)
print("Starting minimal OAuth authorization server...")
print("Available endpoints:")
print(" POST /register - Client registration")
print(" GET /.well-known/oauth-authorization-server - Server metadata")
print(" GET/POST /authorize - Authorization endpoint (code flow only)")
print(" POST /token - Token endpoint")
print(f" Dummy client_id: {DUMMY_CLIENT_ID}")
print(f" Dummy client_secret: {DUMMY_CLIENT_SECRET}")
app.run(host='0.0.0.0', port=8081, debug=True)
Expected behavior
For "token_endpoint_auth_methods_supported": ["client_secret_basic"] , the token endpoint should be called with Basic authorization header using the client_id and client_secret.
(It's also the preferred default method in the absence of an explicit token_endpoint_auth_methods_supported - See https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1 )
Additional context
The regression was most likely introduced by : #715
Since the client_secret is not stored in the local storage anymore, it is not present in the clientInformation passed to selectClientAuthMethod and hasClientSecret evaluates to false, never choosing client_secret_basic.
Recommendations
To avoid storing and encrypting the client_secret altogether, we could add an option in the OAuth flow to either trigger the DCR registration endpoint every time without caching client_id and client_secret, or receive those credentials from a user text input.
Activity
olaservo commentedon Aug 20, 2025
Adding the link to the related PR here since the link in the issue description got malformed: #715
Fix OAuth token endpoint authentication method regression (issue mode…
cliffhall commentedon Aug 20, 2025
@olaservo I ran the Flask server from this issue's description and verified that:
npx @modelcontextprotocol/inspector@0.16.3.npx @modelcontextprotocol/inspector@0.16.4Both failures happen at the Token request step:
application/x-www-form-urlencodedand that's why the server is croaking.I'm not certain how the changes in #175 actually cause this failure. I was expecting a failure due to the client secret not being sent. But instead it appears that it's because of the shape of the payload.
olaservo commentedon Aug 21, 2025
@cliffhall ok interesting, thanks for testing it. I'll set my PR back to a draft or close it for now while we continue to investigate this one.
shlomiuziel commentedon Aug 21, 2025
@cliffhall @olaservo Thank you very much for looking into this!
The exception you encountered @cliffhall is a bit misleading, I updated the demo to reflect that and the root cause still remains - the
/tokenendpoint fails because of a missingclient_secret, and that's the relationship to #715cliffhall commentedon Aug 21, 2025
Thanks @shlomiuziel
Tried the updated server and I see that I get back
{"error": "Invalid client credentials"}and that the goal posts have moved a bit since the last time.In my testing with the previous incarnation I was seeing this in the AS metadata...
Now it is...
All well and good, it should be able to handle all valid challenges.
@felixweinberger @pcarleton Could you have a look? Run the above server in the description and connect Inspector 0.16.3 and 0.16.4 to it. Definitely the latter fails while the former doesn't. I do not see the expected Authorization request header in the
/tokenrequest with client_id and client_secret encoded.shlomiuziel commentedon Aug 22, 2025
@cliffhall Thanks, I'm not sure though why you received
client_secret_post, because the auth server metadata hasn't changed in any recent edit.The core issue was always a missing
client_secret , with the reproduction scenario describing the mismatch between the token endpoint auth method declared in the auth server metadata (client_secret_basic) vs the actual chosen method (client_secret_post) - because theclient_secretfetched from theregisterendpoint was dropped.tmfahey commentedon Aug 28, 2025
Not only is token_endpoint_auth_methods_supported not respected, but in MCP Inspector v0.16.5 and v0.16.4 client_secret is not being provided alongside client_id in the token request.
v0.16.3 works, but always uses client_secret_basic regardless of what token_endpoint_auth_methods_supported is set to.
A regression has definitely occurred with the release of 0.16.4
davidthing commentedon Sep 18, 2025
My testing confirms @tmfahey and @cliffhall's findings. 0.16.3 is the last version where the oauth was working.
client_secret_post, and theclient_secretis set correctly in the body.client_secret_basic, and theAuthorizationheader is set correctly.client_secret_basic, but theAuthorizationheader is not set.In my testing, I have never seen
token_endpoint_auth_methods_supportedbe respected.cliffhall commentedon Sep 19, 2025
It will bear some research, because each of these Inspector versions also may have SDK upgrades to which some of this behavior may be attributed. Most of the auth behavior should be encapsulated in the SDK, so that clients aren't all having to figure it out themselves.
Let's go ahead and do some of this research here for posterity:
client_secret_basicandclient_secret_postdo not appear anywhere in the Inspector codebase.selectClientAuthMethodfunction, based on the supported methods and whether a client secret is present.selectClientAuthMethodis not called from anywhere in the Inspector codebaseselectClientAuthMethodis called fromexchangeAuthorizationandrefreshAuthorizationin the SDK.supportedMethodsargument ofselectClientAuthMethodis drawn frommetadata.token_endpoint_auth_methods_supported(see title of this issue).oauth-state-machinecallsexchangeAuthorizationduring thetoken_requeststep.metadata_discoverystep and placed in local storage.cliffhall commentedon Sep 19, 2025
@tmfahey testing your Flask server above, I see it appears that the client secret is not passed in the
client_secret_basictoken request, and thus that request is failing.I suspect it could have been lost because of a change that was made to keep from placing sensitive information in localstorage. Specifically we filtered out the
client_secret. If we are storing it in a previous step and later retrieving it for use, that could be the problem. In the above research I did see that we were saving the resource metadata and retrieving it at a later step. I will investigate this now.cliffhall commentedon Sep 19, 2025
Can confirm that if I don't filter out

client_secretthat the token request succeeds.cliffhall commentedon Sep 22, 2025
@shlomiuziel So...
If we store the
client_secretwe can retrieve it again after the redirect and send it along to the/tokenrequest endpoint, making your example AS above happy. But we can't do that without encrypting it. The reason it was removed from storage previously was that CodeQL marked it as storing sensitive data, and the justification was that we weren't using it again after the redirect.This was slightly confusing, since we clearly needed it to send in the token request to your server after the redirect. Soon, we will see that we never should have used a client secret at any stage.
I spent a day this weekend working out a scheme to encrypt the
client_secretso it could be somewhat safely stored across the redirect boundary. While it satisfies this server, if the Inspector were ever hosted, and an XSS attack were able to exfiltrate the client id and secret from local storage, it could impersonate the client and get tokens from the AS outside our app. And if that exfiltrating code were able to run in the browser, it could also hit our proxy to get the key (which I was purposely not storing in local storage). So that's why I say somewhat safely, really not at all, just a little more difficult.A little more research and comprehension of this problem has revealed that public clients cannot be expected to keep secrets at all and so a server accepting connections from a public client MUST implement PKCE and accept client id and code challenge/verifier at the token request call. Thus, you should not expect it to honor a
token_endpoint_auth_methods_supportedsetting of anything butnone, sinceclient_secret_basicandclient_secret_postcannot not apply to a public client.davidthing commentedon Sep 23, 2025
@cliffhall, do I understand correctly that MCP Inspector has dropped support for confidential clients? That is, it only supports a
token_endpoint_auth_method/token_endpoint_auth_methods_supportedofnone? The way you phrased it implied that the servers are doing something wrong, but it's the MCP Inspector that is failing to honor the server's declared auth methods:The root problem seems to be that MCP Inspector previously acted like a confidential client, but it did not store the client secret on the server. Instead of fixing that — which was possible, since MCP Inspector does have a server-side component — MCP Inspector has dropped support for confidential clients. This leaves a few unsolved problems:
token_endpoint_auth_methods_supportedtoken_endpoint_auth_methodnoneis supported@cliffhall, do you recommend that folks open new GH issues for those problems?
This screenshot below shows how MCP Inspector (
0.16.8) indicates that the metadata and client registration steps completed successfully, even though the responses in steps 1 and 2 provide enough information to MCP Inspector that the oauth flow cannot succeed. When the token request fails, the error information is blank:tmfahey commentedon Sep 24, 2025
I agree with @davidthing - it really does look like support for confidential clients that previously worked has now been dropped. If that’s the direction Inspector is going, there are still a lot of other unsolved problems that need to be addressed. Right now the tool doesn’t respect token_endpoint_auth_methods_supported, it ignores the registered client’s declared token_endpoint_auth_method, and when the token request fails the user isn’t given any useful error information.
Inspector is in a unique spot. It runs like a public client, but its job is to test private/confidential MCP servers. Many of those expect client_secret_basic or client_secret_post. Without support for those flows, Inspector can’t fully exercise the OAuth configurations it’s meant to validate.
I understand the concern about storing secrets, but for a developer tool it feels like a reasonable tradeoff. If the client_secret were stored encrypted only for the duration of the OAuth flow under test, developers could test confidential servers without leaving sensitive data lying around.
If confidential clients really won’t be supported going forward, then the docs and UI need to make that clear - otherwise people will just keep running into these confusing failures.
pcarleton commentedon Sep 24, 2025
I've put up a PR to restore the original behavior w/ a longer write-up of pro's and cons, see #827
cliffhall commentedon Sep 24, 2025
@davidthing In light of this pushback, I took this back to the team for a re-evaluation. We constantly have pressure from a security perspective to get things right. But we are also a development tool that is meant to help server developers get things right. This was one of those places where the two were at odds. The decision was to go back to the way things were and from there, work toward a more secure stance with a server-stored secret.
To really nail that, it will likely mean a more complex setup with the server handling the whole redirect and token request process. The proposed solution where I kept the key on the server would be hackable and really only a speed bump, so an XSS attack would just as easily be able to request the secret from the server as well. We'll see where this road leads us.