Skip to Content
GuidesMCP OAuth 2.1

MCP OAuth 2.1

Overview

TxNod’s MCP server at https://mcp.txnod.com/api/mcp/transport is a fully-conformant OAuth 2.1 protected resource per MCP Authorization spec rev 2025-11-25 . Compliant MCP clients (Claude.ai web, Cursor 1.x, ChatGPT Connectors, …) auto-discover the authorization server, register themselves, run a browser-based authorization-code flow, and call MCP tools with the resulting access token. PATs (Personal Access Tokens) remain available for STDIO clients (Claude Desktop) and headless / CI use — see the PAT vs OAuth comparison below.

Discovery flow walkthrough

The full discovery chain is three round-trips, all driven by the client.

Step 1. The client makes any MCP tool call without an Authorization header. The server responds 401 Unauthorized with a header pointing at the resource metadata (cite MCP §3.8 ):

HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer realm="txnod-mcp", resource_metadata="https://mcp.txnod.com/.well-known/oauth-protected-resource"

Step 2. The client follows the resource_metadata URL and reads the protected-resource metadata JSON (RFC 9728 ):

{ "resource": "https://mcp.txnod.com/api/mcp/transport", "authorization_servers": ["https://txnod.com"], "scopes_supported": [ "projects:read", "invoices:read", "transactions:read", "webhooks:read" ], "bearer_methods_supported": ["header"], "resource_documentation": "https://docs.txnod.com/guides/mcp-claude-code", "resource_signing_alg_values_supported": [], "resource_name": "txnod MCP server" }

Step 3. The client appends /.well-known/oauth-authorization-server to the AS URL and reads the RFC 8414  metadata:

{ "issuer": "https://txnod.com", "authorization_endpoint": "https://txnod.com/oauth/authorize", "token_endpoint": "https://txnod.com/oauth/token", "registration_endpoint": "https://txnod.com/oauth/register", "revocation_endpoint": "https://txnod.com/oauth/revoke", "scopes_supported": [ "projects:read", "invoices:read", "transactions:read", "webhooks:read" ], "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none", "client_secret_post"], "service_documentation": "https://docs.txnod.com/guides/mcp-oauth", "client_id_metadata_document_supported": true, "resource_documents_supported": [ "https://mcp.txnod.com/.well-known/oauth-protected-resource" ] }

The two *-supported flags matter: code_challenge_methods_supported: ["S256"] says PKCE is mandatory and plain is rejected; client_id_metadata_document_supported: true says CIMD-style registration is honored alongside DCR.

Registering your client (CIMD preferred, DCR fallback)

TxNod accepts two registration paths, both per RFC 7591  family:

CIMD (preferred — Client ID Metadata Document). Host a JSON document at any HTTPS URL containing:

{ "client_id": "https://your-app.example/cid.json", "client_name": "Your App", "redirect_uris": ["https://your-app.example/oauth/callback"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none" }

Pass the URL itself as client_id to /oauth/authorize. TxNod fetches and caches the document with SSRF guards in place — private, loopback, and link-local IPs are blocked at resolution time, and the document body is bounded.

DCR (fallback — Dynamic Client Registration). POST https://txnod.com/oauth/register with the client metadata JSON:

POST /oauth/register HTTP/1.1 Host: txnod.com Content-Type: application/json { "client_name": "Your App", "redirect_uris": ["http://127.0.0.1:43219/callback"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none" }

Reply:

HTTP/1.1 201 Created Content-Type: application/json { "client_id": "01HZZZZZZZZZZZZZZZZZZZZZZZ", "client_id_issued_at": 1714435200, "client_secret_expires_at": 0, "client_name": "Your App", "redirect_uris": ["http://127.0.0.1:43219/callback"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none" }

client_id is a 26-character ULID. Confidential clients (those that supply a client_secret) get a non-rotating secret returned alongside; client_secret_expires_at: 0 means “never expires” per RFC 7591 §3.2.1 .

Rate limit. /oauth/register accepts 10 requests per hour per source IP. Beyond that the endpoint returns 429 Too Many Requests with a Retry-After header.

Redirect-URI validation. Each entry in redirect_uris must be either a loopback URI (http://127.0.0.1:<port>/..., http://localhost:<port>/..., or http://[::1]:<port>/...) or an HTTPS URL — no wildcards, no path prefixes, no plain HTTP outside loopback. http://evil.com/cb is rejected with 400 invalid_redirect_uri.

PKCE + S256 authorization code flow

Every authorization code exchange MUST use PKCE with S256 (cite OAuth 2.1 §7.5  and RFC 7636 ).

Generate the verifier. Pick a 43-128 character base64url string. Use the RFC 7636 example:

code_verifier = dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

Compute the challenge. code_challenge = base64url(sha256(code_verifier)). For the verifier above:

code_challenge = E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

In Node:

import { createHash } from 'node:crypto'; const code_challenge = createHash('sha256').update(code_verifier).digest('base64url');

Send the authorization request.

GET https://txnod.com/oauth/authorize ?response_type=code &client_id=<id> &redirect_uri=<uri> &scope=invoices:read &state=<opaque> &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM &code_challenge_method=S256 &resource=https%3A%2F%2Fmcp.txnod.com%2Fapi%2Fmcp%2Ftransport

User signs in and approves. TxNod requires the user to authenticate via email-OTP or Google OAuth and then renders a consent screen showing the requested scopes. Unknown emails are structurally rejected — TxNod does not auto-create accounts from an OAuth flow; only previously-invited operators can complete the flow (TxNod is invite-only sign-in by design).

On approve, TxNod 302-redirects to the registered redirect URI:

HTTP/1.1 302 Found Location: <redirect_uri>?code=oac_<32-char-body>&state=<opaque>

On deny, the redirect carries error=access_denied&state=<opaque> per OAuth 2.1 §4.1.2.1 .

Send the token request.

POST /oauth/token HTTP/1.1 Host: txnod.com Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=oac_<32-char-body> &redirect_uri=<same> &client_id=<id> &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk &resource=https%3A%2F%2Fmcp.txnod.com%2Fapi%2Fmcp%2Ftransport

Reply:

{ "access_token": "txnod_oat_<32-char-body>", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "txnod_ort_<32-char-body>", "scope": "invoices:read" }

Call MCP tools.

POST /api/mcp/transport HTTP/1.1 Host: mcp.txnod.com Authorization: Bearer txnod_oat_<32-char-body> Content-Type: application/json {"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}

Resource parameter (RFC 8707) — required

Every /oauth/authorize and /oauth/token request must include the resource parameter pinned to the canonical MCP transport URI:

resource=https://mcp.txnod.com/api/mcp/transport

This is the audience-binding requirement from RFC 8707 . Mismatch between the resource value at /oauth/authorize and /oauth/token (or between either and the canonical URI) returns 400 invalid_target. The MCP transport rejects any access token whose stored resource column does not equal the canonical URI verbatim — even if the token signature is otherwise valid. This blocks token-substitution attacks where a token issued for one audience is replayed against another.

Token TTLs

  • Access token TTL = 3600 s (1 hour). Hardcoded — not tunable in v1. Industry-standard (Google, Microsoft, Auth0, Hydra), aligned with RFC 9700 §2.2.1 .
  • Refresh token TTL = 30 days from issuance, rolling on rotation. Each successful refresh issues a new refresh token whose expires_at = now + 30 days (NOT inherited from the consumed token). Active clients (one or more uses per 30 days) live indefinitely; abandoned tokens auto-expire.

Rotation is mandatory per OAuth 2.1 §6.1.2 : every refresh-token use mints a new refresh token; the old one is marked consumed in the same transaction.

Replay of a consumed refresh token cascade-revokes the entire lineage — every access and refresh token descended from the original issuance is revoked synchronously, and an oauth.refresh.replay_detected audit row is written. The next MCP call from any token in that lineage returns 401 invalid_token. This is the standard refresh-token theft response.

Per-client revocation via /account/connected-apps

The dashboard’s /account/connected-apps page lists every OAuth client the operator has approved. Each row carries a Revoke button:

  • Click revoke → cascade-revokes all access and refresh tokens for that (user, client) pair within ≤ 5 s.
  • The next MCP tool call from that client returns 401 invalid_token.
  • The consent record is also revoked, so the next /oauth/authorize from the same client renders a fresh consent screen instead of being auto-approved.

Programmatic revocation is also available via RFC 7009 :

POST /oauth/revoke HTTP/1.1 Host: txnod.com Content-Type: application/x-www-form-urlencoded token=<token> &token_type_hint=access_token &client_id=<id>

Idempotent: TxNod always returns 200 OK with an empty body, even when the token is unknown or already revoked, per RFC 7009 §2.2 . This is intentional — leaking whether a token existed would reveal information to an attacker probing tokens. Revoking an access token does not cascade to its parent refresh token (the client may legitimately want to refresh and continue); revoking a refresh token cascade-revokes every access token issued from its lineage.

Scope challenge (HTTP 403 + WWW-Authenticate insufficient_scope)

When the bearer token’s scopes do not cover the requested tool, the MCP transport returns 403 Forbidden:

HTTP/1.1 403 Forbidden WWW-Authenticate: Bearer error="insufficient_scope", scope="webhooks:read", resource_metadata="https://mcp.txnod.com/.well-known/oauth-protected-resource", error_description="Tool get_webhook_events requires scope webhooks:read" Content-Type: application/json {"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Tool requires additional scope.","data":{"error_code":"insufficient_scope"}}}

The JSON-RPC frame in the body still carries -32600 / insufficient_scope for backward compat. MCP clients that understand step-up auth (per MCP §“Scope Challenge Handling” ) re-run the OAuth flow with the additional scope and retry.

PAT vs OAuth — when to use which

ScenarioUse PATUse OAuth
Claude Desktop (STDIO transport)Always — the only credential mode the wrapper acceptsN/A — STDIO is excluded by MCP spec §“Protocol Requirements”
Claude Code, Cursor, ChatGPT Connectors (HTTP transport)Optional — paste-style “advanced” path remains supportedDefault — auto-discovers, prompts browser, zero manual copy
Headless CI / scriptsAlways — OAuth flow needs a browserN/A
Per-client revocation UX/developer/tokens (manual revoke per token)/account/connected-apps (one-click revoke per client; cascades to all access + refresh tokens)
Audit-trail granularityOne row per tokenOne row per (user, client) consent + per token + per call
Rate-limit poolShared mcp_tool_per_token (60/min, keyed by pat:<token_id>)Same mcp_tool_per_token (60/min, keyed by oauth:<token_id>)
Sandbox tool access (sandbox_simulate_*, sandbox_clock_advance, sandbox_reset, sandbox_delete)Yes, with the sandbox:simulate scope on the PATNosandbox:simulate is not in the OAuth scope set (which is structurally fixed at the 4 reads above). Use a PAT for any agent that drives sandbox tools.
OAuth scope setn/a (PAT-specific scopes)Fixed at projects:read / invoices:read / transactions:read / webhooks:read — write scopes (:write, webhooks:manage, admin:all, sandbox:simulate) are PAT-only by design

See also