AIARTICLE

MCP and OAuth 2.1: How to Harden Servers That Expose Internal Data to AI Agents

The Model Context Protocol's authorization specification defines a subset of OAuth 2.1 with PKCE, audience validation, and a ban on token passthrough. Here's what changes for those building MCP servers in Node.js with a Supabase backend.

MCP and OAuth 2.1: How to Harden Servers That Expose Internal Data to AI Agents
Image: Alan Andrade

Connecting an AI agent to internal data seems simple until the first uncomfortable question comes up: who authorized this access, and how does the server know for certain that the token it received was issued for it, and not for another service? The Model Context Protocol's 2025-06-18 authorization specification answers this by adopting a subset of OAuth 2.1 at the HTTP transport level. And, unlike earlier versions of MCP, the design clearly separates the roles of resource server and authorization server.

The point worth noting for those building in Brazil is that the spec isn't generic security hype: it lists requirements with MUST and MUST NOT that, if ignored, turn an MCP server into a leak vector. It's worth breaking down what actually changes.

The Three Roles: Resource, Client, and Authorization Server

The specification maps everything to OAuth 2.1. A protected MCP server acts as the resource server: it accepts requests authenticated by an access token and responds. The MCP client (the agent's host) acts as the OAuth client, making requests on behalf of the resource owner. And the authorization server, which issues the tokens, is a separate entity that can be hosted together with the resource server or apart from it. Its implementation is, deliberately, outside the scope of the spec.

An important operational detail: authorization is optional in MCP. If the transport is STDIO (the server running locally, communicating via stdin/stdout), the spec explicitly states that it should not follow this flow, and should instead retrieve credentials from the environment. OAuth 2.1 comes into play when the transport is HTTP, that is, when the MCP server is exposed on the network, exactly the case of exposing internal data to remote agents.

Discovery: How the Client Finds the Authorization Server

The flow begins with the client not knowing where to authenticate. The spec solves this with standardized metadata:

  • The MCP server must implement OAuth 2.0 Protected Resource Metadata (RFC 9728), with the authorization_servers field pointing to at least one authorization server.
  • When the client requests without a token, the server responds with 401 Unauthorized and a WWW-Authenticate header pointing to the resource metadata URL.
  • The client must know how to parse this header and, from it, discover the authorization server and its endpoints via OAuth 2.0 Authorization Server Metadata (RFC 8414).

In practice, this means the MCP client doesn't need prior manual configuration: it discovers where to authenticate by reacting to the 401. The spec also recommends (SHOULD) support for Dynamic Client Registration (RFC 7591), so the client can obtain a client_id without human intervention, something that reduces friction when the agent encounters new MCP servers.

The resource Parameter: The Piece That Prevents Confused Tokens

Here's what separates a secure MCP server from a vulnerable one. The spec requires (MUST) the client to implement Resource Indicators for OAuth 2.0 (RFC 8707), including the resource parameter in both the authorization request and the token request. This parameter ties the token to the specific MCP server where it will be used.

The value must be the server's canonical URI. The spec gives valid and invalid examples:

| URI | Valid? | Reason | |---|---|---| | https://mcp.example.com/mcp | Yes | canonical form with path | | https://mcp.example.com | Yes | no trailing slash (recommended) | | https://mcp.example.com:8443 | Yes | explicit port allowed | | mcp.example.com | No | missing the scheme | | https://mcp.example.com#fragment | No | contains a fragment |

In the authorization request, this appears encoded like this:

&resource=https%3A%2F%2Fmcp.example.com

And the client must send this parameter even if the authorization server doesn't support it, to ensure future adoption.

Server-Side Validation: Audience and the Passthrough Ban

On the MCP server side, the rule is strict. Every access token arrives in the header, never in the query string:

GET /mcp HTTP/1.1
Host: mcp.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

The server must validate that the token was specifically issued for it, checking the audience claim (aud), per RFC 8707 and RFC 9068. A token with the wrong audience receives 401. Insufficient scope returns 403. A malformed request, 400.

The most important part for those building an architecture with their own backend is the explicit ban on token passthrough. If the MCP server needs to query an upstream API (say, Supabase), it acts as an OAuth client for that API and uses a separate token, issued by the upstream's authorization server. The spec is categorical:

A MCP server MUST NOT pass through the token it received from the MCP client.

Forwarding the client's token downstream is the classic confused deputy: the downstream API ends up trusting a token that was never validated for it, and an attacker reuses legitimate credentials across services. Accepting tokens with the wrong audience and passing them along are, according to the spec, the two critical dimensions of this vulnerability.

A Concrete Case: An MCP Server in Node.js on Supabase

Consider the scenario from this piece's angle: an agent that needs to query internal orders stored in Supabase's Postgres. The path that makes sense to follow, respecting the spec:

  1. The MCP server (HTTP) publishes its Protected Resource Metadata and responds with 401 and WWW-Authenticate when there's no token.
  2. The client discovers the authorization server, performs the code flow with PKCE, and obtains a token whose audience is the MCP server's canonical URI, via the resource parameter.
  3. The MCP server receives the Bearer token, validates the signature, expiration, and, above all, that aud matches itself.
  4. To read from Supabase, the server does not use the agent's token. It uses its own service credential (the service_role key or a dedicated key stored in an environment variable), applying the Row Level Security rules that make sense for that context.

This step 4 is where many rushed implementations go wrong. The temptation to pass the user's token directly to Supabase saves code, but it violates the spec and blurs the boundaries of trust. The correct approach is for the MCP server to mediate: it validates who's calling, decides what to expose, and queries the backend with its own credential, returning only what that consent authorizes.

PKCE, Short-Lived Tokens, and Redirection: The Rest of the Bill

The Security Considerations close out the design:

  • PKCE is mandatory for the client, preventing authorization code interception and injection.
  • Authorization servers must issue short-lived tokens, and for public clients they must rotate refresh tokens, reducing the damage from a leak.
  • All endpoints over HTTPS; redirect URIs only localhost or HTTPS, validated by exact match against pre-registered values, to block open redirect and phishing.
  • The client must use and verify the state parameter, discarding responses whose state doesn't match.

When It's Not Worth It

If the MCP server runs locally via STDIO, on the user's own machine, building this whole OAuth 2.1 apparatus is overhead with no payoff: the spec says to use credentials from the environment. The authorization flow is only justified when the transport is HTTP and the server is exposed to remote clients, which is exactly the case of exposing corporate data to third-party agents. For those in that situation, the specification leaves no room for shortcuts: validated audience, PKCE, and zero passthrough are requirements, not suggestions.

Translated from the Brazilian Portuguese original · Read the original

View profile →