Skip to content
Skip to main content
A matte-black hardware security key standing in a steel holder on dark slate, representing remote MCP server authentication
9 min readBy Carlos Aragon

How to Authenticate a Remote MCP Server

A remote MCP server is a public HTTP endpoint that hands an AI model your tools, so it needs real auth. The spec's answer is OAuth 2.1: return a 401 with a WWW-Authenticate header pointing at your protected resource metadata, let the client discover your authorization server and run an authorization code flow with PKCE, and then validate that your own URL is in the token's audience claim. That last step is the one people skip — and it's the one that matters most.

Local and Remote MCP Servers Have Nothing in Common Here

Almost every MCP server anyone has actually written runs over stdio — a local subprocess the client launches on your machine. That server has no auth code in it, and it's right not to. The operating system already drew the boundary: if you can spawn the process, you were already trusted, and the spec tells you to pass credentials in environment variables and stop there.

Then you deploy the same server behind a URL so your team, or a hosted agent, or Claude on someone else's laptop can reach it. Now there is no shared trust at all. Anyone who can resolve the hostname can attempt a tools/call. The transport changed from a pipe you own to Streamable HTTP over the open internet, and every assumption underneath your credential handling silently evaporated.

The one-line version:

stdio inherits the OS trust boundary. Remote has no trust boundary until you build one. Copying a stdio server's credential handling to a remote deployment is the single most common way people expose their tools to the internet by accident.

And people do it a lot. A survey of public MCP servers found that only about 8.5% implement the OAuth 2.1 flow the spec requires for remote deployment, while roughly half expose credentials as hard-coded values in config files. That's not a protocol problem. That's everyone shipping the local pattern to a remote address.

The OAuth 2.1 Handshake, in Four Steps

The spec looks intimidating because it cites five RFCs. The actual flow is short. Your server is an OAuth resource server— it never issues tokens, it only validates them. Someone else (Auth0, Keycloak, WorkOS, Entra, your own IdP) is the authorization server. Here's the whole conversation:

1. client -> server   POST /mcp   (no token)
   server -> client   401 Unauthorized
                      WWW-Authenticate: Bearer
                        resource_metadata="https://mcp.example.com
                          /.well-known/oauth-protected-resource"

2. client -> server   GET /.well-known/oauth-protected-resource
   server -> client   { "resource": "https://mcp.example.com",
                        "authorization_servers": ["https://id.example.com"] }

3. client <-> IdP     authorization code + PKCE (S256)
                      ...&resource=https%3A%2F%2Fmcp.example.com
   IdP    -> client   access_token  (aud: "https://mcp.example.com")

4. client -> server   POST /mcp
                      Authorization: Bearer <token>
   server             verify sig + exp + scopes + AUDIENCE -> run the tool

Step 1 and step 2 exist purely so the client can figure out who to ask for a token without you writing documentation. If your server returns a bare 401 with no WWW-Authenticate header, or if /.well-known/oauth-protected-resource 404s, auto-discovery dies and the client either fails silently or dumps a manual-configuration prompt on your user. That metadata endpoint must be served over HTTPS and without authentication — a surprising number of people put it behind the same middleware as everything else and then wonder why nothing can connect.

Step 3 is ordinary OAuth with two non-negotiables: PKCE with S256 (no exceptions, even for confidential clients), and the resource parameter on both the authorization request and the token request. Clients are told to send that parameter whether or not they think the authorization server supports it.

The Step Everyone Skips: Audience Binding

That resource parameter is RFC 8707 — resource indicators. It lets the client say “mint me a token for this specific server,” and the authorization server stamps that URL into the token's aud claim. Your job on every request is to confirm your own canonical URL is in that claim.

Most implementations validate the signature and the expiry, see a well-formed token from a trusted issuer, and call it a day. Here's what that costs you: any other service using the same identity provider can hand your server a token that was never issued for you, and your server will run the tool. Your MCP server becomes a confused deputy— it has legitimate access to a database, a CRM, an ad account, and it's executing on behalf of a caller it never actually authorized.

The check, in one line:

Valid signature + valid issuer is not authorization. audmust contain your canonical resource URL, and you must reject the request when it doesn't — before any tool executes, not after.

Two related rules worth writing on the wall. First, your MCP server must never accept a token it did not receive directly from its own client — no forwarding a token it was handed by an upstream service. Second, if your server is itself a client of some downstream API, it needs its owntoken for that API. Passing the incoming token through is the same confused-deputy bug wearing a different hat, and it's a tempting shortcut because it looks like it's “preserving the user's identity.”

When You Shouldn't Build OAuth At All

Here's the part the spec write-ups leave out: most self-hosted MCP servers are not multi-tenant products.They're internal tools. One owner, a handful of known machines, no third-party clients. Standing up an identity provider, a metadata endpoint, dynamic client registration, and a token-validation middleware for that is weeks of work protecting a server three agents will ever call.

What I actually run: the MCP servers I self-host sit behind a reverse proxy that enforces identity before the request reaches the app. My SEO audit server runs in Docker on a NAS at home and is exposed on a subdomain fronted by Cloudflare Access with a service-token policy. My client sends two headers — a client ID and a client secret issued for that one service token — and Access either lets the request through to the container or drops it at the edge. The app itself never sees an unauthorized request. Same pattern for the Home Assistant instance I drive from an agent.

That's not a downgrade from OAuth in every dimension — the token is revocable in one click, scoped to one named client, logged per request, and rotated without touching a line of code. What it doesn't give you is per-user identity or delegated consent. Which is exactly the tradeoff: if your server needs to know which human is behind the call, you need the real flow. If it just needs to know the caller is you, proxy auth is a real trust boundary and it ships this afternoon.

ApproachUse it whenWhat it can't do
stdio + env varsServer runs locally as a subprocess of the clientAnything remote. Full stop.
Static bearer tokenA bridge you have already scheduled to removeExpire, scope, rotate, or audit itself
Proxy service tokenPrivate server, clients you own, one tenantPer-user identity or delegated consent
Full OAuth 2.1Public or multi-tenant, third-party clients, per-user scopesShip in an afternoon

Pick the row that matches your actual deployment, not the row that sounds most professional. The failure I see is teams picking row four, not finishing it, and running row two in production for eight months while the OAuth branch rots. A finished proxy token beats an unfinished authorization server every single time.

Whatever You Choose, the Credential Discipline Is the Same

The hard-coded-credential number from earlier is the real story. It isn't that people chose the weak option — it's that the secret ends up in a JSON config file that gets synced to a laptop, pasted into a support thread, screenshotted in a tutorial, and committed to a repo that goes public two years later. The auth mechanism barely matters if the secret leaks through the config.

  • Secrets live in environment variables or a secret manager — never inline in a checked-in config file.
  • Every credential has a written expiry and a rotation owner. A token nobody is responsible for rotating is a permanent one.
  • One credential per client, never one shared across everything. Revocation should cost you one caller, not all of them.
  • Log every authorization decision, including the denials. Denials are the only early warning you get.
  • Scope the tools, not just the connection. Read-only agents should hold read-only credentials at the API underneath too.

One more thing that isn't auth but behaves like it: every authenticated tool you expose is a tool an agent can be talked into calling. Auth answers “who is calling.” It says nothing about whether the model was manipulated into making the call. Keep the destructive tools behind a confirmation or off the remote server entirely — the same reason I argue for giving agents a CLI instead of a sprawling MCP surface when the task allows it.

Pre-Flight Checklist Before You Expose the URL

Run this before the hostname is public, not after. It takes twenty minutes and it's the difference between a server and an incident:

  • An unauthenticated request returns 401 — not 200, not 500, not a partial tool list.
  • The 401 carries a WWW-Authenticate header with a resource_metadata pointer (if you're doing OAuth).
  • /.well-known/oauth-protected-resource is reachable over HTTPS with no auth and returns resource + authorization_servers.
  • A token issued for a different resource is rejected — actually test this, don't assume your library does it.
  • An expired token is rejected, and the error is a clean 401 rather than a stack trace.
  • TLS everywhere. No plaintext transport, no self-signed shortcut you promise to fix later.
  • Every tool the server exposes is one you'd be comfortable seeing in an audit log at 3am.

The canonical reference is the MCP authorization specification, and the discovery mechanics come straight from RFC 9728. Read both once before you write the middleware — they're shorter than the blog posts about them.

The Short Version

  • stdio inherits the OS trust boundary; remote MCP has none until you build one.
  • The handshake is: 401 + WWW-Authenticate → protected resource metadata → PKCE with a resource parameter → audience-validated token.
  • Signature + issuer is not authorization. Check that aud contains your URL, or you've built a confused deputy.
  • Never forward a token you were handed. Downstream calls need the server's own credential.
  • Private, single-tenant server with clients you own? A proxy service token is a real boundary and ships today.
  • The mechanism matters less than the discipline: secrets out of config files, one credential per client, a written rotation owner.

If you're still deciding whether a remote server is even the right shape for your agent, I broke down the token economics in what an MCP server really costs you in context and the packaging tradeoff in Claude Skills vs MCP. For a worked example of a production server in the wild, there's the Hyros MCP setup.

Need an MCP Server You Can Safely Put on the Internet?

I build and deploy production MCP servers and AI agent infrastructure — proper auth boundaries, scoped credentials, audit logging, and tools that won't wreck a database when a model gets creative. If you've got a server that works locally and needs to work remotely without becoming a liability, let's talk.

Related Posts

AI Agents

MCP Apps: Interactive UI Inside Your AI Client

MCP Apps are the first official Model Context Protocol extension (shipped Jan 26, 2026): an MCP tool can now return a real interface — a dashboard, form, chart, or multi-step wizard — that the client renders in a sandboxed iframe right inside the chat, instead of plain text. Three parts make it work: a ui:// resource (bundled HTML/JS), a tool linked to it via _meta.ui.resourceUri, and an App class that speaks two-way JSON-RPC over postMessage so the UI can receive the tool result, call server tools, and push the user's selection back into the model's context. It's a cross-client standard — Claude, ChatGPT, VS Code, and Goose already render the same UI resource. Reach for an App only when the user needs to see or manipulate something; plain text tools still win for short answers. Bonus: rendering data in a UI instead of narrating 500 rows back through the model can cut token cost, not add it.

AI Agents

MCP vs CLI for AI Agents: The 4–32× Token Tax Nobody Warns You About

For most agent tasks a CLI is 4–32× cheaper than an MCP server — 1,365–8,750 tokens per task instead of 32,000–82,000 — because every connected MCP server injects all of its tool definitions into every turn, used or not. One Microsoft Intune test came out ~35× cheaper on the CLI (~4,150 vs ~145,000 tokens), and at 10k ops/month that's roughly $3.20 vs $55.20. The CLI was also more reliable in one 75-run benchmark (100% vs 72%, MCP's failures were mostly TCP timeouts on its persistent connection). Use a CLI when a mature one exists and you own the box; keep MCP for OAuth SaaS, multi-tenant per-user auth, governance/audit needs, and tools with no CLI. For high-volume fan-out, let the model write code that orchestrates the calls (programmatic tool calling / Code Mode) to cut tokens 98–99%. The best agents mix all three; measure tokens per completed task, not per call.

AI Agents

Claude Skills vs MCP: When I Reach for Each (and the Token Cost That Decides It)

A skill is knowledge, an MCP server is a connection — use a skill to teach the model how, use MCP to let it reach a system it can't otherwise touch. The tiebreaker most people skip is token cost: skills sit idle at ~30–100 tokens each, while five MCP servers can burn ~55k tokens before you type a word. The exact rule I run in production.