When an MCP client's OAuth integration goes wrong, exercising the gateway's
endpoints by hand is the fastest way to figure out where. This guide walks every
step of the downstream OAuth flow using curl, openssl, and jq. Each step
shows the request, the shape of the response, and what to look for.
The flow being tested is the standard MCP authorization handshake: discovery →
registration → authorize → token → MCP request → refresh. Read the
authentication overview for the conceptual model first.
The user-consent step is browser-based — there's no scriptable way to complete
it from a terminal. Steps 4 through 6 show the URL to open in a browser and the
redirect to inspect; the rest of the flow runs in your terminal.
Prerequisites
curl, jq, openssl, and a Bash-compatible shell.
A deployed MCP Gateway with an MCP OAuth policy
(Auth0 or generic OIDC)
configured and at least one /mcp/{slug} route.
A browser to complete the user-consent step.
Throughout this guide, replace:
GATEWAY with your gateway origin (e.g., https://gateway.example.com).
SLUG with the route slug (e.g., linear-v1).
REDIRECT_URI with a redirect URL that you can monitor — for testing,
http://localhost:8765/callback works because the URL only needs to capture
the code query parameter.
An unauthenticated request to an MCP route should return a 401 with a
WWW-Authenticate header that points at the per-route Protected Resource
Metadata document.
If code_challenge_methods_supported doesn't include S256, something is wrong
with the gateway configuration. The spec requires S256 and the gateway always
advertises it.
Step 3: Register a client (DCR)
For this test, register a public client with
token_endpoint_auth_method: "none". This is the simplest mode and matches what
a CLI client would use.
Build the authorize URL. The resource parameter is required by the MCP
spec on every authorization and token request.
Code
AUTH_URL="${AUTH_ENDPOINT}?response_type=code&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&code_challenge=${CODE_CHALLENGE}&code_challenge_method=S256&state=${STATE}&scope=mcp:tools&resource=$(printf %s "$RESOURCE" | jq -sRr @uri)"echo "Open this URL in a browser:"echo "$AUTH_URL"
Open the URL in a browser. The flow is:
The gateway redirects you to your IdP's login page.
You authenticate at the IdP.
The IdP redirects back to the gateway's /oauth/callback.
The gateway renders the consent setup page.
You click Authorize.
The gateway redirects to your redirect_uri with ?code=...&state=....
Capture the code value from the final redirect URL. There's no listener on
http://localhost:8765, so the browser shows a connection-refused page — that's
expected. Copy the code value out of the address bar.
The authorization code is single-use and short-lived (typically 30 seconds). Run
the next step immediately after copying it.
Code
read -p "Enter the authorization code from the redirect URL: " AUTH_CODE
Step 5: Exchange the code for tokens
POST /oauth/token with the authorization-code grant. Public clients send
client_id in the form body; confidential clients use HTTP Basic.
If you see a JSON-RPC error with code: -32042 (URLElicitationRequiredError),
the upstream MCP server requires OAuth and the user hasn't connected to it
yet. Open the authUrl in the error payload's data field in a browser. See
Per-user OAuth to upstream MCP servers for the full
flow.
If you see a 401, the bearer token is missing, expired, revoked, or bound to a
different route — the response WWW-Authenticate header includes a reason code
via error="...".
If you see a 403 with error="insufficient_scope", the token has the wrong
scope. The gateway only issues mcp:tools today.
Step 7: Refresh the access token
The access token expires in 15 minutes by default. Exchange the refresh token
for a new pair using the refresh_token grant.
The refresh token rotates on every use. Presenting the old refresh token again
will revoke the entire grant — that's the spec's defense against
refresh-token replay. Always use the most recently issued refresh token.
The new access token can be used immediately on subsequent /mcp/{slug}
requests.
Per RFC 7009, the gateway responds with 200 OK and an empty body for both
successful revocations and unknown tokens. Subsequent MCP requests with the
revoked access token return 401.
Putting it all together
Here's a single Bash script that runs every step except the browser-based
authorize redirect. Save it as test-oauth.sh and run it after editing the
configuration block at the top.
401 on every MCP request after token exchange. Token bound to a
different route than the one you're calling. Each token binds to the
operationId of the route used during authorize. Either re-run for the
intended route or call the route you authorized for.
401 with error="invalid_token" after a token reuse. Refresh tokens
rotate on every use — presenting an old one revokes the entire grant. Re-run
the full flow.
invalid_request at the token endpoint. Most often a missing resource
parameter or a missing code_verifier. Both are required.
invalid_grant at the token endpoint. The authorization code expired or
was already redeemed. Re-run from step 4.
invalid_audience. The bearer token is being used at a route whose
canonical resource URI doesn't match the token's resource claim. The gateway
derives the canonical URI from the request origin — a misconfigured custom
domain or proxy can cause this.
The browser shows the gateway's consent page but the Authorize button is
disabled. The route has an upstream that hasn't been connected yet. Click
the per-upstream Connect button first. See
upstream OAuth.
JSON-RPC error -32042 (URLElicitationRequiredError). The downstream
OAuth succeeded but the upstream MCP server requires OAuth and the user hasn't
connected. Open the authUrl in the error payload's data field in a
browser.