DEV Community

Opula
Opula

Posted on

What building a remote MCP server taught me about authentication

When I started building Opula - a hosted MCP server that lets Claude read your portfolio, net worth, and cash flow so you can just ask about your own money - I assumed the hard part would be the finance logic. It wasn't. The hard part was authentication.

MCP moves fast, the auth story changed twice in 2025, and most of the "add auth to your MCP server" tutorials online are already out of date. So here is the mental model I wish I'd had on day one: how transports shape your auth options, what the current spec actually requires, and the standards (and anti-patterns) that matter in production.

First: auth depends entirely on your transport

MCP defines two standard transports, and they live in completely different security worlds.

STDIO Streamable HTTP
Deployment Local subprocess Remote network service
Clients 1:1 with the host N clients, multi-user
Network exposure None Yes
Auth model OS process isolation, env vars OAuth 2.1, bearer tokens, API keys, mTLS
Cross-machine No Yes

(SSE was a third, earlier transport. It's deprecated now - if you're starting today, don't.)

The takeaway: if your server runs locally over STDIO, you mostly don't need auth. The client already trusts the process it spawned, and you pass secrets via environment variables. The official guidance is explicit about this: for STDIO, use environment-based credentials instead of OAuth.

The moment you go remote over Streamable HTTP - which is what any hosted/SaaS MCP server is - the picture flips. Now you have a single HTTP endpoint (say POST /api/mcp) reachable over the network, potentially by many users, and you own the entire authentication and authorization story.

Opula is remote by nature (your Claude talks to https://opula.io/api/mcp), so everything below is about the HTTP side.

The spec you should actually read: 2025-06-18

The MCP authorization spec was revised in June 2025, and this revision is the one to build against. The single most important sentence in it:

A protected MCP server acts as an OAuth 2.1 resource server.

That one line reframes the whole problem. Your MCP server is not an identity provider. It doesn't own login screens or issue tokens. It validates tokens that some authorization server issued, and it serves protected resources (your tools) when the token checks out.

The June revision made two changes worth knowing if you read older tutorials:

  1. It cleanly separated the resource server (your MCP server) from the authorization server. You're encouraged to delegate token issuance to a dedicated auth server rather than bolt an OAuth provider onto your tool server.
  2. It removed the old fallback default endpoints (/authorize, /token, /register) in favor of mandatory metadata discovery via RFC 9728.

The standards stack

Modern MCP auth is basically four OAuth RFCs wearing a trench coat. Here's the whole cast:

RFC Role What it does
RFC 9728 Resource Server Protected Resource Metadata - your server advertises which auth servers to trust
RFC 8414 Authorization Server AS Metadata - the auth server advertises its endpoints
RFC 7591 Client Dynamic Client Registration - clients register themselves without manual setup
RFC 8707 Token audience Resource Indicators - tokens are bound to a specific target resource

Plus OAuth 2.1 Authorization Code + PKCE as the grant flow. Let's walk the flow the way a client actually experiences it.

The discovery flow, step by step

1. Client hits your endpoint with no token. You reject it with a 401 - but not a bare 401. You include a WWW-Authenticate header pointing at your metadata:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="mcp",
  resource_metadata="https://opula.io/.well-known/oauth-protected-resource"
Enter fullscreen mode Exit fullscreen mode

2. Client fetches your Protected Resource Metadata (RFC 9728). This is a tiny JSON document at a well-known URL that says "here's who I am and who issues tokens for me":

{
  "resource": "https://opula.io/api/mcp",
  "authorization_servers": ["https://auth.opula.io"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": ["mcp:tools:read", "mcp:portfolio:read"]
}
Enter fullscreen mode Exit fullscreen mode

This is the piece that older guides skip and the June spec makes mandatory. The client no longer guesses your auth endpoints - you tell it.

3. Client discovers the authorization server (RFC 8414), optionally registers itself (RFC 7591), and runs a standard OAuth 2.1 Authorization Code flow with PKCE. PKCE is not optional here: MCP clients like Claude Desktop and Cursor are public clients that can't safely store a client secret, so the spec mandates PKCE for everyone.

4. Client requests a token bound to your resource (RFC 8707). It sends a resource parameter so the issued token's audience (aud) is specifically your server, not "any API this user can reach."

5. Client retries with the token:

POST /api/mcp HTTP/1.1
Host: opula.io
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Enter fullscreen mode Exit fullscreen mode

Now you validate and serve.

Validating the token on the server

Whatever framework you use, token validation on a resource server comes down to the same checks. Here's the shape of it in a Node handler (Opula runs on Node on Vercel):

import { StreamableHTTPServerTransport }
  from "@modelcontextprotocol/sdk/server/streamableHttp.js";

async function authorize(req: Request): Promise<Claims> {
  const header = req.headers.get("authorization");
  if (!header?.startsWith("Bearer ")) throw new Unauthorized();

  const token = header.slice("Bearer ".length);
  const claims = await verifyJwt(token, { jwks: AUTH_SERVER_JWKS });

  // The checks that actually matter:
  assert(claims.iss === TRUSTED_ISSUER);            // who issued it
  assert(claims.aud === "https://opula.io/api/mcp"); // issued FOR us (RFC 8707)
  assert(claims.exp > nowSeconds());                 // not expired
  assertScopes(claims.scope, requiredScopes(req));   // allowed to do this

  return claims;
}
Enter fullscreen mode Exit fullscreen mode

The audience check (aud) is the one people drop, and it's the one that stops the scariest attacks. Which brings me to the mistakes.

The three anti-patterns I had to design around

1. Token passthrough - explicitly forbidden. The tempting shortcut is: let the client hand you a token for some downstream API (say a brokerage PAT) and just forward it. The spec says MUST NOT. If you accept tokens that weren't issued for you, you break audit trails, blur identities in downstream logs, and turn your server into a proxy for stolen tokens.

The fix is a design rule: the MCP server is the system of record. Clients authenticate to you with MCP-scoped credentials; you then use your own managed credentials to call downstream services. Opula calls its market-data sources (like the FRED economic data API) with server-side keys that never touch the client - which, conveniently, is exactly what "no token passthrough" demands.

2. The confused deputy. If your server uses a single static client ID to talk to a third-party provider on behalf of every user, that provider may skip the consent screen for a request that looks trusted. An attacker can ride that to get a user authorized to something they never intended. Mitigation: dynamic client registration and explicit user consent, strict redirect-URI validation.

3. Forgetting you're on a network. Two cheap but critical HTTP-transport rules from the spec: validate the Origin header on every connection (prevents DNS-rebinding attacks), require HTTPS in production, and if you ever run locally, bind to 127.0.0.1 rather than 0.0.0.0. One more from hard-won ops experience: scrub the Authorization header out of your logs. It's a bearer token; logging it is the same as logging a password.

The pragmatic path: start with bearer, grow into OAuth

Here's the honest part. Full RFC 9728 + 8414 + 7591 + 8707 + PKCE is the destination, not necessarily your day-one commit. Plenty of shipping MCP servers - Opula included - start with a validated bearer token over Streamable HTTP: the client sends Authorization: Bearer <token>, middleware verifies signature, issuer, audience, expiry, and scope before the request ever reaches the MCP transport layer, and everything not explicitly authorized gets a clean 401.

That gets you a secure, multi-user server today. The value of understanding the full spec is that it tells you exactly where the seams are when you grow: the day a client wants to self-register, or an enterprise wants its own IdP to issue the tokens, you already know which RFC slots in where instead of re-architecting.

TL;DR

  • Pick your transport first. STDIO = local, use env vars, skip OAuth. Streamable HTTP = remote, you own auth.
  • Your MCP server is an OAuth 2.1 resource server, not an identity provider. It validates tokens; it doesn't mint them.
  • Discovery is mandatory now: 401 + WWW-Authenticate -> Protected Resource Metadata (RFC 9728) -> auth server -> Authorization Code + PKCE -> audience-bound token (RFC 8707).
  • Always check aud. A token not issued for your server is a token you must reject.
  • Never do token passthrough. Be the system of record; use your own downstream credentials.
  • You can ship a validated bearer token today and grow into the full flow - as long as you know where the seams are.

Further reading

I'm building Opula, a hosted MCP server that turns Claude into an analyst for your own money. If you're building MCP servers too, I'd love to compare notes on where you drew the auth line.

Top comments (0)