mcpstepbystep

Tutorials Builder

How to add OAuth to an MCP server, step by step

Add OAuth 2.1 to a remote MCP server without hand-rolling it: the resource-server model, RFC 8707 audience checks, DCR vs CIMD, and a middleware sketch.

updated 2026-07-19 ⏱ 50–60 min

OAuth is where most MCP journeys stall. The spec chapter is long, the RFC numbers pile up, and half the blog posts describe the pre-2025 model that no longer applies. Here is the honest structure of the problem: there are only two authentication situations in MCP, one of them is trivial, and the other follows one fixed pattern that you configure rather than build. Let’s take it calmly.

Step 1 — Decide whether you need OAuth at all

Local stdio server? You do not need OAuth. The server runs as you, on your machine, and secrets go in the env block of your client config:

{
  "mcpServers": {
    "github-tools": {
      "command": "/absolute/path/to/server",
      "env": { "GITHUB_TOKEN": "ghp_..." }
    }
  }
}

That is not a compromise; it is the right design for a single-user local process. Adding OAuth here is complexity with no threat model behind it.

Remote server? OAuth 2.1 is the spec’s answer, and in practice it is not optional: ChatGPT connectors and claude.ai connectors expect OAuth (ChatGPT specifically OAuth 2.1 with dynamic client registration), and an authless URL on the public internet is an incident waiting for a timestamp. If you deployed in the previous tutorial, this is your situation.

Step 2 — Learn the one model that matters: your server is a resource server

Since spec 2025-06-18, the MCP server’s role is fixed: it is an OAuth 2.1 resource server. It never issues tokens, never shows login pages, never stores passwords. It does exactly two jobs:

  1. Tell clients where to get a token (discovery).
  2. Validate the tokens that come back (signature, audience, scope, expiry).

Everything else — login UI, consent, token issuance — belongs to an authorization server: Okta, Auth0, Entra ID, Keycloak, Stytch, WorkOS, or a library that plays that role for you. This split is the single most important thing to understand, because it means you never build the hard part.

The full first-connection flow:

sequenceDiagram
    participant C as MCP client
    participant S as MCP server (resource server)
    participant A as Authorization server
    C->>S: POST /mcp (no token)
    S-->>C: 401 + WWW-Authenticate (resource metadata URL)
    C->>S: GET /.well-known/oauth-protected-resource
    S-->>C: metadata (authorization_servers, scopes)
    C->>A: OAuth 2.1 + PKCE, resource=https://mcp.example.com/mcp
    A-->>C: access token bound to your server
    C->>S: POST /mcp + Authorization Bearer token
    S->>S: validate signature, audience, scopes
    S-->>C: tool results

Two RFCs make this work, and both are simpler than their numbers suggest:

  • RFC 9728 (protected resource metadata) — your server publishes a JSON document at /.well-known/oauth-protected-resource saying “here is my identity, and here are the authorization servers that vouch for me”. The 401 response’s WWW-Authenticate header points clients at it. This is how a client you’ve never met finds your login flow with zero configuration.
  • RFC 8707 (resource indicators) — when the client requests a token, it names the specific resource the token is for (resource=https://mcp.example.com/mcp). The authorization server bakes that into the token’s audience. Your server then refuses any token whose audience isn’t itself. This one check is what stops a token stolen from some other service being replayed against your tools.

Step 3 — Understand how clients register: DCR vs Client ID Metadata Documents

An authorization server normally requires clients to be registered in advance. But MCP clients are arbitrary — any user might connect any client — so pre-registration doesn’t scale. Two mechanisms solve this:

  • Dynamic Client Registration (DCR): the client registers itself with your authorization server on first contact via an API call. This is what ChatGPT connectors use today, so your authorization server must support it if ChatGPT is a target client.
  • Client ID Metadata Documents (CIMD): the client’s ID is an HTTPS URL pointing to a document describing it (name, redirect URIs). The authorization server fetches and verifies it — no registration database, and the client’s identity is auditable. Spec 2025-11-25 recommends CIMD as the preferred direction.

Practical position for mid-2026: enable both if your provider supports them; require exact redirect-URI matching either way (this matters for security — see the next tutorial on the confused-deputy problem).

Step 4 — Pick an implementation path (do not hand-roll)

Hand-rolling OAuth means implementing PKCE, token signing, key rotation, consent screens, and every published attack’s mitigation. Nobody reviews that code as hard as attackers do. Choose one of these instead:

PathWhen it fits
Cloudflare workers-oauth-provider libraryYour server is on Workers (previous tutorial’s template has an auth variant). The library implements the provider endpoints; you write a handler and the token validation is done for you.
External IdP (Okta, Auth0, Entra ID, Keycloak, Stytch, WorkOS…)You already have an IdP, or you want auth managed outside your codebase. Your server only validates JWTs against the IdP’s published keys.
Your SDK’s auth helpersBoth official SDKs ship auth utilities for the resource-server side (metadata routes, token verification hooks). Use them rather than raw HTTP handling.

Verdict: if you’re on Cloudflare, use their library; otherwise point at an external IdP and keep your server a pure resource server. The authoritative reference for what your server must do is the spec’s authorization chapter: modelcontextprotocol.io/specification/2025-11-25/basic/authorization.

Step 5 — Sketch the middleware: what validation actually checks

Whatever path you chose, the resource-server logic reduces to one middleware in front of /mcp. Here is the shape of it in TypeScript — treat this as an architecture sketch to adapt, not drop-in code; your provider’s SDK supplies the verifyJwt equivalent:

// Sketch — pair with your provider's JWT verification library.
app.use("/mcp", async (req, res, next) => {
  const token = req.headers.authorization?.replace(/^Bearer /, "");

  if (!token) {
    // Point the client at your RFC 9728 metadata — this triggers the whole flow.
    res.setHeader(
      "WWW-Authenticate",
      'Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"'
    );
    return res.status(401).end();
  }

  let claims;
  try {
    claims = await verifyJwt(token, {
      issuer: "https://auth.example.com",          // only your authorization server
      audience: "https://mcp.example.com/mcp",     // RFC 8707: issued *for this server*
    });
  } catch {
    return res.status(401).end();                  // bad signature, expired, wrong audience
  }

  if (!claims.scope?.includes("mcp:tools")) {
    return res.status(403).end();                  // authenticated, but not allowed
  }

  req.auth = claims;                               // tools can now know *who* is calling
  next();
});

Three checks, in order of how often they’re skipped in the wild: issuer (who signed it), audience (was it issued for me), scope (is it allowed to do this). The audience check is the one most hand-rolled implementations forget, and it’s the one the spec is strictest about.

Step 6 — Never pass tokens through

A tempting shortcut: your MCP server wraps some upstream API, the client already sent a bearer token, so you forward that same token upstream. The spec forbids this outright — a server must not accept or forward tokens that were not issued to it — and the reason is worth internalising.

If your server passes tokens through, it stops being a security boundary. Audience validation breaks (the upstream API sees a token minted for your server, or worse, your server accepts tokens minted for the upstream API). Any client that obtains a token for service A can now reach service B through you, and the upstream’s logs attribute everything to the wrong principal. This is the “token passthrough” entry in the spec’s security-best-practices doc, and it is a hard MUST NOT.

The correct pattern: your server validates the inbound token, then uses its own credential (its own OAuth client, or a service account) for upstream calls — exchanging identity explicitly rather than smuggling it.

Step 7 — Know about Enterprise-Managed Authorization (EMA)

One section for readers deploying inside a company. The flow above has each user consenting per client per server — fine for individuals, unmanageable for an organisation of thousands. The Enterprise-Managed Authorization extension (stable as of July 2026, driven by Anthropic, Microsoft and Okta) moves that decision to the enterprise IdP: administrators pre-authorise which clients may reach which MCP servers, users sign in once via SSO, and the IdP issues identity assertions (ID-JAG) that the authorization flow consumes. Users stop seeing consent screens for servers their admin already approved, and access is revocable centrally.

Troubleshooting

Client loops forever on 401 Unauthorized — authenticates, retries, 401 again The token is being issued but failing validation, so the client re-authenticates endlessly. Decode the token (any JWT inspector) and compare iss and aud to what your middleware expects — the loop is almost always an issuer URL with a trailing-slash mismatch, or an audience of the authorization server instead of your MCP server. Fix the resource parameter or the middleware constant, not the client.

redirect_uri_mismatch during the login redirect The redirect URI the client sent isn’t an exact string match for what’s registered. OAuth 2.1 requires exact matching — no wildcards, no “close enough” on ports or trailing slashes. Register the precise URI the client reports, and if you’re using DCR, check your provider isn’t normalising URIs on registration.

invalid audience (or aud claim mismatch) rejecting every token The client didn’t send a resource parameter, or your authorization server ignored it, so the token’s audience is generic. Confirm your provider supports RFC 8707 resource indicators and that your protected-resource metadata advertises the exact canonical URL (scheme, host, path) your middleware checks. The two strings must agree byte-for-byte.

Everything worked yesterday; today one client gets 403 with a valid login Stale cached token. Clients cache access tokens for their lifetime, so scope or audience changes on the server don’t take effect until the token expires or is discarded. In Claude Code, claude mcp remove / add forces a fresh flow; in ChatGPT, delete and recreate the connector. If this bites often, shorten token lifetimes at the authorization server.

ChatGPT refuses to add the connector at all ChatGPT requires OAuth 2.1 with dynamic client registration. If your authorization server has DCR disabled (many IdPs ship it off by default), the connector fails before you ever see a login page. Enable DCR, or front your IdP with a provider that offers it.

Next step

Authentication decides who gets in; it says nothing about what a malicious tool description, a hijacked session, or a typosquatted dependency does once things are running. The next tutorial is the defensive sweep: the full MCP threat catalogue and a checklist you can run against any deployment.

newsletter

One practical MCP guide in your inbox. No news, no hype.

Tutorials and decision frameworks as they ship. Unsubscribe anytime.