Matin Labkhandagh

Guide

Production MCP OAuth architecture: connectors, relays and the security mistakes to avoid

What the MCP authorization spec actually requires, where a relay belongs, and the failure modes I design against after running MCP connectors in OmidGPT and an MCP-operated admin in AiMatin.

Matin LabkhandaghProduction AI & Agentic Systems EngineerPublished · 15 min read

In production, an MCP server is an OAuth 2.1 resource server and nothing more exotic than that. It validates tokens that were issued specifically for it, it never forwards those tokens anywhere, and it scopes what a caller may do per user and per tool. Where the server has to talk to third-party providers (a calendar, a mail API, a payment gateway), I put a relay between the agent and those providers: the relay holds the provider tokens, the agent receives capabilities rather than secrets, consent is recorded per tool, and every call is written to a ledger before and after it runs. The rest of this guide is the reasoning behind each of those sentences, grounded in the specification text and in what broke when I ran this pattern in OmidGPT.

What does the MCP specification say about authorization?

The authorization section of the protocol is short, and reading it directly saves a lot of argument. The MCP authorization specification states that authorization is optional, that implementations on an HTTP transport SHOULD conform to it, and that STDIO implementations SHOULD NOT follow it and instead read credentials from the environment. The mechanism is described as a selected subset of OAuth 2.1 plus a handful of companion documents: OAuth 2.0 Authorization Server Metadata (RFC 8414), Protected Resource Metadata (RFC 9728), and, in the current revision, OAuth Client ID Metadata Documents, with Dynamic Client Registration (RFC 7591) kept as a MAY for backwards compatibility.

The roles are defined precisely, and I quote them because teams routinely get them backwards:

A protected MCP server acts as an OAuth 2.1 resource server, capable of accepting and responding to protected resource requests using access tokens. An MCP client acts as an OAuth 2.1 client, making protected resource requests on behalf of a resource owner.

The authorization server is a separate role. It may be co-hosted with the MCP server or be a different system entirely; the spec explicitly leaves its implementation out of scope. What the spec does mandate is how a client finds it. MCP servers MUST implement RFC 9728 Protected Resource Metadata and the metadata document MUST contain an authorization_servers field with at least one entry. Discovery happens either through a WWW-Authenticate header carrying a resource_metadata URL on a 401 response, or through the well-known URI (/.well-known/oauth-protected-resource, optionally with the MCP endpoint path appended); clients MUST support both and prefer the header when present. RFC 9728 itself defines the well-known location, the resource, authorization_servers, scopes_supported and bearer_methods_supported fields, and the resource_metadata challenge parameter.

Three further requirements shape everything I build on top of this.

  • PKCE is mandatory, and its support must be verified. Clients MUST implement PKCE with the S256 method, and if the authorization server metadata does not advertise code_challenge_methods_supported, the client MUST refuse to proceed. This follows the general direction of the OAuth 2.1 draft, which makes PKCE part of the authorization code flow, removes the implicit and password grants, requires exact redirect URI matching, and forbids bearer tokens in query strings.
  • Token audience is explicit. Clients MUST send the resource parameter from RFC 8707 Resource Indicators in both the authorization request and the token request, set to the canonical URI of the MCP server, and servers MUST validate that a presented token was issued for them. RFC 8707 exists so the authorization server can audience-restrict a token to the resource it will be used at; the MCP spec makes sending that parameter unconditional, whether or not the authorization server honors it.
  • Token passthrough is forbidden. In the spec’s words, MCP servers MUST NOT accept or transit any other tokens, and if the server calls upstream APIs it does so as its own OAuth client with a separate token. This single rule is the reason the relay pattern below exists.

The 2025-11-25 revision also adds scope challenges: a server SHOULD include a scope parameter in its 401 challenge, respond to an under-scoped token with 403 and error="insufficient_scope", and let the client run a step-up authorization for the additional scope. That is the protocol-level hook for least privilege, and it maps well onto per-tool consent.

Where do servers, clients and hosts sit in the auth flow?

MCP separates the host (the application the user is sitting in, which runs the model), the client (the component inside the host that speaks the protocol to one server), and the server (the process exposing tools, resources and prompts). Authorization is negotiated between the client and the server’s authorization server; the model never participates in it, and it should never see a token. The sequence, reduced to what matters operationally, looks like this:

client  -> mcp-server : tools/call without token
mcp-server -> client  : 401, WWW-Authenticate: Bearer resource_metadata="...", scope="calendar:read"
client  -> mcp-server : GET /.well-known/oauth-protected-resource
mcp-server -> client  : { resource, authorization_servers: ["https://auth.example"] }
client  -> auth       : GET /.well-known/oauth-authorization-server
auth    -> client     : metadata (code_challenge_methods_supported must be present)
client  -> browser    : /authorize?code_challenge=...&resource=https://mcp.example/mcp&scope=...
user    -> auth       : authenticates, consents
auth    -> client     : authorization code via registered redirect_uri
client  -> auth       : POST /token  code + code_verifier + resource
auth    -> client     : access token (audience = mcp.example), refresh token
client  -> mcp-server : tools/call, Authorization: Bearer <token>   (on every request)

Two details in that sequence are where production deployments diverge from demos. First, the Authorization header goes on every HTTP request, including requests inside one logical session; the security best-practices page is blunt that servers MUST NOT use sessions for authentication and MUST verify every inbound request. Second, the token the client obtains is for the MCP server only. When the server needs to reach a provider on the user’s behalf, that is a second, independent OAuth relationship in which the MCP server (or the relay in front of the providers) is the client. Keeping those two relationships apart is the whole architecture; collapsing them is the whole class of bugs.

In a LangGraph-based host, the client component lives inside the tool node, and the checkpointer must persist enough state to resume a tool turn after a re-authorization round trip. I cover the state side of that in the LangGraph production architecture guide; here I stay on the trust boundary.

Why put an OAuth relay between the agent and third-party providers?

An agent product with more than a couple of integrations quickly ends up holding tokens for many providers on behalf of many users. If those tokens sit in the same process as the model loop, three things go wrong. Any prompt-injection that reaches a tool with file or network access can exfiltrate them. Any log line that prints a tool call can leak them. And every connector reimplements refresh, rotation and revocation slightly differently. In OmidGPT I moved all of that into one component: an OAuth / MCP relay that sits between the MCP connectors and the providers.

Host and agent call an MCP client, which calls the OAuth relay with a relay-scoped token; the relay checks per-tool consent, writes to the tool-call ledger, and calls the provider with a provider token that never leaves the relay.Host / agentmodel loopMCP clienttools/callOAuth / MCP relayresource server +client to providersProvider APIcalendar, mail...relay tokenprovider tokencapability, never a secretPer-tool consentuser x tool x scopeTool-call ledgerappend-onlyToken vaultencrypted, per usercheck beforewrite before + afterread tokenagent side: no provider secrets

The relay plays two OAuth roles at once, which is exactly what the spec anticipates. Toward the MCP client it is a resource server: it validates the relay-scoped token, checks audience, and returns 401 or 403 with the correct challenge. Toward each provider it is a confidential client with its own client credentials, its own redirect URI and its own refresh loop. The provider token is written to a vault keyed by user and provider, and the only thing that crosses the dashed line back to the agent is a tool result. Agents receive capabilities, meaning the ability to invoke calendar.list_events for this user, not the credential that makes the invocation possible.

A minimal callback route for the provider side, with generic names, looks like this:

// relay: provider OAuth callback (Express-style sketch)
app.get("/oauth/callback/:provider", async (req, res) => {
  const { code, state } = req.query;
  const pending = await consent.takePendingState(state); // single-use, short TTL
  if (!pending) return res.status(400).send("unknown or expired state");

  const grant = await providers[req.params.provider].exchangeCode({
    code,
    codeVerifier: pending.codeVerifier,
    redirectUri: pending.redirectUri, // exact match with the registered value
  });

  await vault.put({ userId: pending.userId, provider: pending.provider, grant }); // encrypted at rest
  await ledger.append({
    userId: pending.userId,
    event: "provider_connected",
    provider: pending.provider,
    scopes: grant.scope,
  });

  return res.redirect(pending.returnTo); // never carries the provider token
});

Note what the route does not do: it does not put the token in the redirect, it does not accept a callback without a pending state, and it does not create the pending state until after the user has approved the relay-level consent screen. That ordering is not a style preference; it is the mitigation the security page prescribes for the confused-deputy attack, discussed below.

Provider-level OAuth scopes are too coarse for an agent. A user who connects a mailbox with a read scope has not agreed that every tool the model discovers may read their mail in every conversation. So consent in my relay is recorded at the intersection of user, tool and scope. The first time an agent tries a tool that touches a provider, the relay returns a consent requirement to the host rather than executing the call; the host shows the user which tool, which provider, which scope; the decision is stored; and the tool turn resumes. Because the turn is resumable, the model does not have to re-plan after the user clicks approve, which matters for cost as much as for correctness.

This is consistent with the tools specification. The MCP tools page says there SHOULD always be a human in the loop with the ability to deny tool invocations, that clients SHOULD prompt for confirmation on sensitive operations and show tool inputs to the user before calling the server, and that servers MUST validate all tool inputs, implement proper access controls, rate limit tool invocations and sanitize tool outputs. It also says clients MUST treat tool annotations as untrusted unless the server is trusted, so a readOnlyHint from an unknown server is a hint, not a policy input.

The ledger is the other half. Every tool call is appended before execution (who, which tool, which arguments after redaction, which consent record authorized it) and again after execution (status, duration, provider response class, error). The ledger is what makes the system operable: it answers the support question of what the agent did in a given conversation, it feeds per-token cost accounting, it is the input to rate limiting, and it is the audit trail the security page says token passthrough would destroy. In OmidGPT the ledger and the resumable tool turns are one design: a turn is a ledger row whose state advances, and resuming means reading that row.

MCP security: SSRF, confused deputy and tool design

The MCP security best practices page catalogs the attacks that matter for this architecture. I summarize the ones I design against, with the mitigation I actually implement.

ThreatHow it shows up in an agent productMitigation
Token passthroughMCP server forwards the client’s bearer token to a providerReject any token not issued for the server; relay obtains its own provider token
Audience confusionA token for service A is accepted by service BSend resource on every auth and token request; validate audience on every call
Confused deputyRelay with a static provider client ID plus dynamic client registration; consent cookie skips the provider screenRelay-owned consent page per client ID; state created only after approval; exact redirect URI match
SSRF through discoveryMalicious server points resource_metadata or authorization_servers at a metadata IPHTTPS only; block private and link-local ranges; validate every redirect hop; egress proxy
SSRF through tool argumentsA tool takes a user- or model-supplied URL and fetches it from inside the networkTools take identifiers, not URLs; allowlisted hosts; resolver pinned between check and use
Prompt injection via tool resultsFetched page or document instructs the model to call another toolResults are data; destructive tools require consent regardless of what the result says
Session hijackingSession ID used as identity across stateful serversVerify the bearer token on every request; random session IDs bound to the user ID
Scope inflationClient requests every scope in scopes_supported up frontMinimal initial scope; step-up via insufficient_scope challenges

Two of these deserve more words. The confused-deputy attack in the spec is specific: it needs a proxy with a static client ID at the provider, dynamic client registration on the MCP side, a provider that sets a consent cookie, and no per-client consent at the proxy. A relay in front of several providers is exactly that proxy. The prescribed fix is a registry of approved client IDs per user, a relay-owned consent page that names the client, the provider scopes and the redirect URI, exact string matching on redirect URIs, and a state value that is generated and stored only after the user approves. The callback sketch above follows that order for that reason.

SSRF has two faces here. The spec’s section covers the discovery face: an MCP client deployed on a server fetches URLs a malicious server chose, and those URLs can point at 169.254.169.254, at localhost services or at a domain that rebinds after validation. The spec says such clients MUST consider SSRF and SHOULD enforce HTTPS, block private ranges, validate redirect targets and consider an egress proxy; it also warns against hand-written IP validation because of encoding tricks. The second face is the one I see more often in reviews: a tool whose input schema contains a free-form URL. Once the model can choose the URL, so can anyone who can influence the model. My rule is that a tool takes an identifier and the server resolves it against an allowlist, as in the definition below.

// MCP tool definition: identifier in, no URL in the schema
const fetchInvoice = {
  name: "billing.fetch_invoice",
  title: "Fetch invoice",
  description: "Read one invoice from the connected billing account by id.",
  inputSchema: {
    type: "object",
    properties: {
      invoiceId: { type: "string", pattern: "^inv_[A-Za-z0-9]{6,32}$" },
    },
    required: ["invoiceId"],
    additionalProperties: false,
  },
  annotations: { readOnlyHint: true, destructiveHint: false },
} as const;

// handler: consent and ledger wrap the provider call
async function handleFetchInvoice(ctx: ToolContext, args: { invoiceId: string }) {
  await consent.require(ctx.userId, fetchInvoice.name, "billing:read");
  const row = await ledger.begin(ctx, fetchInvoice.name, args);
  try {
    const invoice = await relay.call(ctx.userId, "billing", "GET", `/invoices/${args.invoiceId}`);
    await ledger.finish(row, { ok: true });
    return { content: [{ type: "text", text: JSON.stringify(invoice) }] };
  } catch (err) {
    await ledger.finish(row, { ok: false, error: String(err) });
    return { content: [{ type: "text", text: "invoice lookup failed" }], isError: true };
  }
}

The handler is deliberately boring. Consent is checked before anything else, the ledger row is opened before the provider call and closed after it, the relay call carries the user ID and a provider name rather than a token, and a failure is returned as a tool execution error with isError: true rather than as a protocol error, which is the distinction the tools page draws.

Token lifecycle: refresh, revocation, scope reduction

Tokens are the part of this system that keeps working for a week and then fails on a Sunday. The spec’s guidance is that authorization servers SHOULD issue short-lived access tokens and MUST rotate refresh tokens for public clients; the OAuth 2.1 draft adds that refresh tokens should be sender-constrained or rotated. In the relay I treat three lifecycle events as first-class.

Refresh

Refresh belongs to the relay, never to the agent. A tool call that hits an expired provider token triggers a refresh inside the relay, with a per-user lock so that concurrent tool calls do not race to rotate the same refresh token and invalidate each other. If the refresh itself fails with an invalid-grant class of error, the connection is marked as needing re-authorization and the tool returns a consent requirement, not a stack trace.

Revocation

Users disconnect providers, providers revoke grants, and administrators disable tools. All three must land in the same place: the vault entry is removed, the consent records for that provider are marked revoked, and the ledger gets an event. Any in-flight tool turn that reads the vault after that point fails closed. Revocation that only deletes the token and leaves consent standing is a common bug; the next reconnect silently inherits the old approvals.

Scope reduction

The security page calls poor scope design a blast-radius problem and lists the common mistakes: publishing every scope in scopes_supported, wildcard scopes, bundling unrelated privileges, and treating scopes in the token as sufficient without server-side authorization. The step-up flow in the 2025-11-25 authorization spec is the answer: start with the minimal scope, respond to a privileged tool call with 403 and insufficient_scope, let the client re-authorize for the added scope, and cap retries. Per-tool consent slots into this naturally, since a tool declares the scope it needs and the relay can compute the challenge from that.

What fails first, in my experience

Reviewing MCP integrations as part of MCP development and AI agent development work, the same failures recur, and they are rarely cryptographic.

  • The server trusts the session. A session ID is issued on initialize and then used as identity for the rest of the conversation. The spec forbids this; verify the bearer on every request.
  • The agent process holds provider tokens. Usually because the first integration was built without a relay and the second copied it. The fix is architectural and cheaper early.
  • Audience is not checked. The server validates the signature and the expiry and stops. Any token from the same issuer is accepted, so a token for the billing service opens the mail connector.
  • A tool accepts URLs. The web-fetch tool that made the demo impressive is the SSRF vector in production.
  • Consent is global, not per tool. One approval at connect time authorizes every future tool, including ones added later through tools/list_changed.
  • Refresh races. Two parallel tool calls refresh the same token; the provider rotates it; the second call stores a dead token. Serialize refresh per user and provider.
  • No ledger, so no answer. When a user asks what the agent did, the only record is the model transcript, which is neither complete nor trustworthy.

What I observed in OmidGPT and AiMatin

AiMatin is the other end of the spectrum: a solo-operated education and commerce platform where a custom Node.js MCP server lets an AI operator run admin workflows (coupons, popups, funnel automations, reports) against a FastAPI and PostgreSQL back end. There is no third-party provider and no relay, so the authorization question collapses to a single trusted operator, but the tool-design lessons carry over unchanged: tools take identifiers and structured arguments rather than URLs, destructive operations are separate tools from reads, and every operator action is visible afterwards. That is the pattern I reuse in AI automation engagements, where an agent operating an internal system needs the same discipline as one operating on a customer’s mailbox, only with a shorter trust chain.

MCP security checklist

This is the list I work through before an MCP server or relay is exposed beyond a development machine. Each item traces back to a spec requirement or to a failure I have had to fix.

  • The MCP server serves RFC 9728 protected resource metadata with at least one authorization_servers entry, and 401 responses carry WWW-Authenticate with resource_metadata and a minimal scope.
  • Every inbound request validates the bearer token: signature, expiry, issuer, and audience equal to the server’s canonical URI. Sessions are never used as authentication.
  • The server never forwards a client token upstream. Provider access uses a separate token obtained by the relay acting as its own OAuth client.
  • Clients send the RFC 8707 resource parameter in authorization and token requests, use PKCE with S256, and refuse to proceed if code_challenge_methods_supported is absent.
  • All authorization server endpoints and redirect URIs are HTTPS (loopback excepted in development), redirect URIs are matched by exact string, and state is random, single-use and short-lived.
  • The relay has its own consent page per client ID and per user, created before the provider redirect, showing client name, provider scopes and redirect URI, with CSRF and clickjacking protection.
  • Consent is recorded per user, per tool and per scope; new tools announced via tools/list_changed do not inherit prior approvals.
  • Provider tokens live in an encrypted vault keyed by user and provider; they never appear in tool results, redirects, logs or model context.
  • Refresh is serialized per user and provider; invalid-grant errors mark the connection for re-authorization; revocation removes the token and the consent records together.
  • Tool input schemas do not accept free-form URLs; any outbound fetch goes through an allowlist and an egress path that blocks private, loopback and link-local ranges and validates each redirect hop.
  • Tool results are treated as untrusted data; destructive or write tools require consent regardless of instructions found in prior results.
  • Every tool call is appended to a ledger before execution and updated after, with redacted arguments, the authorizing consent record, outcome and duration.
  • Scopes start minimal; privileged tools trigger a 403 insufficient_scope challenge and a bounded step-up flow rather than an up-front request for everything.
  • Rate limits and timeouts apply per user and per tool, and the ledger is the source those limits read from.

If you want this list applied to an existing system, the MCP development service covers relay design, connector hardening and the review of an implementation against the specification text quoted here.

Further reading