ZuploZuplo
LoginSign Up
  • Documentation
  • API Reference
Introduction
Getting Started
    Develop using the Portal
      1 - Setup Your Gateway2 - Rate Limiting3 - API Key Auth4 - Deploy5 - Dynamic Rate LimitingMCP - Quick start
    Develop Locally
      1 - Setup Your Gateway2 - Rate Limiting3 - API Key Auth
Concepts
Development
Policies
Handlers
API Keys
MCP Server
MCP Gateway
    IntroductionBetaQuickstartQuickstart (Local Dev)How it works
    Connect MCP clients
    Authentication
      OverviewUpstream OAuthConnect an upstream OAuth provider
      Identity providers
      Manual OAuth testing
    Configuration
    Observability
    ReferenceTroubleshooting
AI Gateway
Developer Portal
Monetization
Deploying & Source Control
Observability
Networking & Infrastructure
Account Management
Programming API
Build with AI
Zuplo CLI
Migration Guides
Platform LimitsSecuritySupportTrust & ComplianceChangelog
powered by Zudoku
Authentication

Manual OAuth testing

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 configured (Auth0, Okta, Entra, Google, or any other supported IdP) 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.
TerminalCode
GATEWAY="https://gateway.example.com" SLUG="linear-v1" REDIRECT_URI="http://localhost:8765/callback"
  1. Discover the protected resource.

    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.

    TerminalCode
    curl -i -X POST "${GATEWAY}/mcp/${SLUG}" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":"1","method":"ping"}'

    Expected response:

    Code
    HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer realm="OAuth", resource_metadata="https://gateway.example.com/.well-known/oauth-protected-resource/mcp/linear-v1"

    If you get a 200 instead, the route isn't protected. Check that the MCP OAuth policy is attached to the route in routes.oas.json.

    Now fetch the PRM document:

    TerminalCode
    curl -s "${GATEWAY}/.well-known/oauth-protected-resource/mcp/${SLUG}" | jq

    Expected response shape:

    Code
    { "resource": "https://gateway.example.com/mcp/linear-v1", "resource_name": "Linear MCP Proxy", "authorization_servers": ["https://gateway.example.com/mcp/linear-v1"], "bearer_methods_supported": ["header"], "scopes_supported": ["mcp:tools"], "mcp_protocol_version": "2025-11-25" }

    The authorization_servers array tells the client where to find the AS metadata. For the gateway, the AS lives under the same origin.

  2. Discover the authorization server.

    Fetch the per-route AS metadata document.

    TerminalCode
    curl -s "${GATEWAY}/.well-known/oauth-authorization-server/mcp/${SLUG}" | jq

    Expected response shape (truncated to the fields you care about):

    Code
    { "issuer": "https://gateway.example.com/mcp/linear-v1", "authorization_endpoint": "https://gateway.example.com/oauth/authorize/mcp/linear-v1", "token_endpoint": "https://gateway.example.com/oauth/token", "registration_endpoint": "https://gateway.example.com/oauth/register", "revocation_endpoint": "https://gateway.example.com/oauth/revoke", "scopes_supported": ["mcp:tools"], "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": [ "none", "client_secret_basic", "client_secret_post", "private_key_jwt" ], "client_id_metadata_document_supported": true }

    Capture the URLs you'll need:

    TerminalCode
    AS_METADATA=$(curl -s "${GATEWAY}/.well-known/oauth-authorization-server/mcp/${SLUG}") AUTH_ENDPOINT=$(echo "$AS_METADATA" | jq -r '.authorization_endpoint') TOKEN_ENDPOINT=$(echo "$AS_METADATA" | jq -r '.token_endpoint') REGISTRATION_ENDPOINT=$(echo "$AS_METADATA" | jq -r '.registration_endpoint')

    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.

  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.

    TerminalCode
    DCR_RESPONSE=$(curl -s -X POST "${REGISTRATION_ENDPOINT}" \ -H "content-type: application/json" \ -d "{ \"client_name\": \"Manual OAuth Test\", \"redirect_uris\": [\"${REDIRECT_URI}\"], \"grant_types\": [\"authorization_code\", \"refresh_token\"], \"response_types\": [\"code\"], \"token_endpoint_auth_method\": \"none\", \"scope\": \"mcp:tools\" }") echo "$DCR_RESPONSE" | jq CLIENT_ID=$(echo "$DCR_RESPONSE" | jq -r '.client_id')

    Expected response shape:

    Code
    { "client_id": "dcr:abc123...", "client_id_issued_at": 1747958400, "client_id_metadata_document_supported": true, "client_name": "Manual OAuth Test", "redirect_uris": ["http://localhost:8765/callback"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none", "scope": "mcp:tools" }

    The client ID is opaque. DCR clients expire 90 days after issuance.

  4. Build the authorize URL with PKCE.

    Generate a PKCE verifier and S256 challenge, plus a state value for CSRF.

    TerminalCode
    CODE_VERIFIER=$(openssl rand -base64 64 | tr -d '\n=+/' | cut -c1-128) CODE_CHALLENGE=$(printf "%s" "$CODE_VERIFIER" | openssl dgst -sha256 -binary \ | openssl base64 | tr '/+' '_-' | tr -d '=') STATE=$(openssl rand -hex 16) RESOURCE=$(echo "$AS_METADATA" | jq -r '.issuer') echo "CODE_VERIFIER: $CODE_VERIFIER" echo "CODE_CHALLENGE: $CODE_CHALLENGE" echo "STATE: $STATE" echo "RESOURCE: $RESOURCE"

    Build the authorize URL. The resource parameter is required by the MCP spec on every authorization and token request.

    TerminalCode
    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:

    1. The gateway redirects you to your IdP's login page.
    2. You authenticate at the IdP.
    3. The IdP redirects back to the gateway's /oauth/callback.
    4. The gateway renders the consent setup page.
    5. You click Authorize.
    6. 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.

    TerminalCode
    read -p "Enter the authorization code from the redirect URL: " AUTH_CODE
  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.

    TerminalCode
    TOKEN_RESPONSE=$(curl -s -X POST "${TOKEN_ENDPOINT}" \ -H "content-type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=authorization_code" \ --data-urlencode "code=${AUTH_CODE}" \ --data-urlencode "redirect_uri=${REDIRECT_URI}" \ --data-urlencode "code_verifier=${CODE_VERIFIER}" \ --data-urlencode "client_id=${CLIENT_ID}" \ --data-urlencode "resource=${RESOURCE}") echo "$TOKEN_RESPONSE" | jq ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token') REFRESH_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.refresh_token')

    Expected response shape:

    Code
    { "access_token": "at_...", "token_type": "Bearer", "expires_in": 900, "refresh_token": "rt_...", "scope": "mcp:tools", "resource": "https://gateway.example.com/mcp/linear-v1" }

    A common failure mode here is invalid_grant because the authorization code expired or was already used. Re-run from step 4.

    Another common one is invalid_request if you forget the code_verifier or omit the resource parameter.

  6. Call the MCP endpoint with the access token.

    Now the access token can be presented as a bearer credential on the MCP route.

    TerminalCode
    curl -s -X POST "${GATEWAY}/mcp/${SLUG}" \ -H "authorization: Bearer ${ACCESS_TOKEN}" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "mcp-protocol-version: 2025-11-25" \ -d '{ "jsonrpc": "2.0", "id": "1", "method": "initialize", "params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "manual-test", "version": "0.0.0" } } }' | jq

    Expected response is a JSON-RPC result with the upstream's serverInfo and capabilities:

    Code
    { "jsonrpc": "2.0", "id": "1", "result": { "protocolVersion": "2025-11-25", "capabilities": { "tools": {} }, "serverInfo": { "name": "linear", "version": "..." } } }

    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.

  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.

    TerminalCode
    REFRESH_RESPONSE=$(curl -s -X POST "${TOKEN_ENDPOINT}" \ -H "content-type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=refresh_token" \ --data-urlencode "refresh_token=${REFRESH_TOKEN}" \ --data-urlencode "client_id=${CLIENT_ID}" \ --data-urlencode "resource=${RESOURCE}") echo "$REFRESH_RESPONSE" | jq ACCESS_TOKEN=$(echo "$REFRESH_RESPONSE" | jq -r '.access_token') NEW_REFRESH_TOKEN=$(echo "$REFRESH_RESPONSE" | jq -r '.refresh_token')

    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.

  8. Revoke the tokens (optional cleanup).

    When you're done testing, revoke the grant.

    TerminalCode
    curl -s -i -X POST "${GATEWAY}/oauth/revoke" \ -H "content-type: application/x-www-form-urlencoded" \ --data-urlencode "token=${NEW_REFRESH_TOKEN}" \ --data-urlencode "token_type_hint=refresh_token" \ --data-urlencode "client_id=${CLIENT_ID}"

    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.

TerminalCode
#!/usr/bin/env bash # Manual OAuth flow test for the Zuplo MCP Gateway. # Walks discovery → DCR → authorize URL → code exchange → MCP request → refresh. # The authorize step is browser-based; the script pauses for you to paste the code. set -euo pipefail # ----- Configuration ----- GATEWAY="https://gateway.example.com" SLUG="linear-v1" REDIRECT_URI="http://localhost:8765/callback" # ------------------------- echo "==> Step 1: discover protected resource" PRM_URL="${GATEWAY}/.well-known/oauth-protected-resource/mcp/${SLUG}" echo "PRM: ${PRM_URL}" curl -s "${PRM_URL}" | jq -r '{authorization_servers, scopes_supported, mcp_protocol_version}' echo echo "==> Step 2: fetch AS metadata" AS_METADATA=$(curl -s "${GATEWAY}/.well-known/oauth-authorization-server/mcp/${SLUG}") AUTH_ENDPOINT=$(echo "$AS_METADATA" | jq -r '.authorization_endpoint') TOKEN_ENDPOINT=$(echo "$AS_METADATA" | jq -r '.token_endpoint') REGISTRATION_ENDPOINT=$(echo "$AS_METADATA" | jq -r '.registration_endpoint') RESOURCE=$(echo "$AS_METADATA" | jq -r '.issuer') echo "issuer: $RESOURCE" echo "authorize: $AUTH_ENDPOINT" echo "token: $TOKEN_ENDPOINT" echo echo "==> Step 3: register client (DCR)" DCR_RESPONSE=$(curl -s -X POST "${REGISTRATION_ENDPOINT}" \ -H "content-type: application/json" \ -d "{ \"client_name\": \"Manual OAuth Test\", \"redirect_uris\": [\"${REDIRECT_URI}\"], \"grant_types\": [\"authorization_code\", \"refresh_token\"], \"response_types\": [\"code\"], \"token_endpoint_auth_method\": \"none\", \"scope\": \"mcp:tools\" }") CLIENT_ID=$(echo "$DCR_RESPONSE" | jq -r '.client_id') echo "client_id: $CLIENT_ID" echo echo "==> Step 4: build authorize URL with PKCE" CODE_VERIFIER=$(openssl rand -base64 64 | tr -d '\n=+/' | cut -c1-128) CODE_CHALLENGE=$(printf "%s" "$CODE_VERIFIER" | openssl dgst -sha256 -binary \ | openssl base64 | tr '/+' '_-' | tr -d '=') STATE=$(openssl rand -hex 16) RESOURCE_ENC=$(printf "%s" "$RESOURCE" | jq -sRr @uri) 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=${RESOURCE_ENC}" echo echo "Open this URL in a browser:" echo "$AUTH_URL" echo echo "After completing login and consent, copy the 'code' query parameter from the redirect URL." read -r -p "Enter the authorization code: " AUTH_CODE echo echo "==> Step 5: exchange code for tokens" TOKEN_RESPONSE=$(curl -s -X POST "${TOKEN_ENDPOINT}" \ -H "content-type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=authorization_code" \ --data-urlencode "code=${AUTH_CODE}" \ --data-urlencode "redirect_uri=${REDIRECT_URI}" \ --data-urlencode "code_verifier=${CODE_VERIFIER}" \ --data-urlencode "client_id=${CLIENT_ID}" \ --data-urlencode "resource=${RESOURCE}") echo "$TOKEN_RESPONSE" | jq ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token') REFRESH_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.refresh_token') echo echo "==> Step 6: call MCP endpoint with the access token" curl -s -X POST "${GATEWAY}/mcp/${SLUG}" \ -H "authorization: Bearer ${ACCESS_TOKEN}" \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -H "mcp-protocol-version: 2025-11-25" \ -d '{ "jsonrpc": "2.0", "id": "1", "method": "initialize", "params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": { "name": "manual-test", "version": "0.0.0" } } }' | jq echo echo "==> Step 7: refresh" REFRESH_RESPONSE=$(curl -s -X POST "${TOKEN_ENDPOINT}" \ -H "content-type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=refresh_token" \ --data-urlencode "refresh_token=${REFRESH_TOKEN}" \ --data-urlencode "client_id=${CLIENT_ID}" \ --data-urlencode "resource=${RESOURCE}") echo "$REFRESH_RESPONSE" | jq echo echo "Done."

Make it executable and run it:

TerminalCode
chmod +x test-oauth.sh ./test-oauth.sh

Common issues

  • 401 on every MCP request after token exchange. Token bound to a different route than the one you're calling. Each token is scoped to one MCP route. 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. 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.

Related

  • Authentication overview — the full identity provider catalog and per-IdP setup links.
  • Per-user OAuth to upstream MCP servers
  • Test clients — exercise the same OAuth flow through the MCP Inspector and MCPJam GUIs instead of curl.
  • MCP authorization spec, revision 2025-11-25
Edit this page
Last modified on May 27, 2026
Generic OIDCSet up the gateway
On this page
  • Prerequisites
  • Putting it all together
  • Common issues
  • Related
JSON
JSON
JSON
JSON
JSON