Rightshold™ Sign up

Answers · MCP security

How do I do OAuth token exchange / OBO so an MCP server never uses the user's long-lived token upstream?

Last updated

TL;DR: Do not pass the client's bearer token through to GitHub, Slack, your API, or any other upstream. MCP's security best practices call that "token passthrough" and forbid it: an MCP server must only accept tokens issued for that server, then exchange them (RFC 8693 token exchange / on-behalf-of) for a new, short-lived token whose audience is the downstream API. Keep the user's subject, narrow the scopes, put the MCP server (and the agent client) in the act delegation chain, cache the exchanged token until near expiry, and log user id + agent/client id on every tools/call. The user's long-lived refresh or personal access token never leaves the identity provider or the MCP server's own vault.

What should I check, step by step?

1. Stop token passthrough: reject inbound tokens whose aud/resource is not this MCP server, and never put that inbound token on an Authorization header to an upstream host (MCP Security Best Practices).

  1. Register the MCP server as an OAuth client that can perform grant_type=urn:ietf:params:oauth:grant-type:token-exchange (RFC 8693) or your IdP's documented on-behalf-of / token-exchange profile (Auth0 documents on_behalf_of_token_exchange for MCP → first-party API).
  2. On each authenticated tools/call (or on cache miss): exchange subject_token=<user access token for this MCP>, subject_token_type=access_token, audience=<downstream API>, scope=<minimum for this tool>. Prefer resource indicators (RFC 8707) when your AS supports them.
  3. Require short expires_in on the exchanged token; cache by (user, agent/client, audience, scope) until shortly before expiry — Auth0's OBO guidance warns that re-exchanging every call burns rate limits.
  4. Preserve identity in the issued JWT: same sub as the user, aud set to the downstream API, and an act claim that records the MCP server (and nested client) as actors so audit logs show who acted for whom.
  5. Log every upstream call with stable user id, agent or MCP client id, tool name, audience, scopes used, and exchange/cache hit — not the raw tokens.
  6. Never store the user's long-lived refresh token in the agent session, AGENTS.md, or a workspace file the model can read; keep refresh material in the MCP server's secret store and rotate on revoke.
  7. Fail closed: if exchange fails, audience mismatches, or scopes were reduced below what the tool needs, return an auth error to the agent instead of falling back to a shared service account or the original user token.

Why is forwarding the user token the wrong pattern?

MCP Security Best Practices define token passthrough as accepting a client token without proving it was issued for the MCP server and sending that same token to a downstream API. The spec says MCP servers MUST NOT accept tokens that were not explicitly issued for them.

What does a correct OBO exchange look like?

RFC 8693: POST to the token endpoint with grant_type urn:ietf:params:oauth:grant-type:token-exchange, a required subject_token + subject_token_type, and optional audience, resource, and scope. The AS returns access_token, issued_token_type, token_type, and usually expires_in.

What can go wrong?

The MCP server forwards the browser or IDE token unchanged because "the API already accepts it" — classic passthrough.

How do I ask Rightshold about this (free, no key)?

Add the MCP server https://www.rightshold.com/mcp to Claude, Cursor, ChatGPT, Grok or VS Code, then ask: “Record a signed receipt for this MCP upstream call: hashes of the user id, agent/client id, tool name, downstream audience and scopes I exchanged for — without storing the access tokens.”

One-click setup

{
  "tool": "record_ai_run",
  "arguments": {
    "model": "<mcp-server-or-agent-id>",
    "provider": "mcp-obo",
    "prompt_sha256": "<sha256 of user_id|agent_id|tool|audience|scope>",
    "output_sha256": "<sha256 of allow/deny + expires_at>",
    "params": {
      "user_id": "auth0|user123",
      "agent_id": "spa_client_id",
      "tool": "create_issue",
      "audience": "https://api.example.com",
      "scope": "issues:write",
      "decision": "exchanged",
      "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange"
    },
    "ran_at": "2026-11-06T20:00:00Z"
  }
}

Rightshold records signed run receipts and spend/approval decisions for agents that call it. It does not run your IdP, perform RFC 8693 exchanges for you, or stop an MCP server that still forwards tokens. Wire exchange in your authorization server and MCP process. Not legal advice.

What are the sources?

Related

Not legal advice. JSON · Markdown · All answers

Get a free API key in one call

No card and no email needed. 100 free credits; the license and policy checks stay free without a key.

curl -s -X POST https://www.rightshold.com/v1/accounts/signup -H "Content-Type: application/json" -d '{"accept_terms":true}'
export RIGHTSHOLD_API_KEY=rs_...   # the api_key from the reply (shown once)
curl -s https://www.rightshold.com/v1/licenses/check -H "Authorization: Bearer $RIGHTSHOLD_API_KEY" \
  -H "Content-Type: application/json" -d '{"license":"MIT","intended_use":{"commercial_use":true}}'