# Authenticating MCP servers

Source: https://tpiros.dev/blog/authenticating-mcp-servers

Go back and read almost any MCP walkthrough, mine included, and the server has no idea who's calling it. Any client that can reach the endpoint gets the same data as any other. My own [Sales Explorer](/blog/build-an-mcp-app-interactive-ui-in-the-chat) is wide open like that: one set of numbers, no questions asked.

For a demo that's fine. For anything touching a user's own data it isn't, and for a while MCP had no answer here. The protocol shipped in November 2024 with no authorization story at all, and that's the part of the spec that has moved most since. March 2025 added OAuth. The [June 2025 revision](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) did the real work: it classified an MCP server as an OAuth 2.1 *resource server*, added protected-resource metadata so clients can discover the authorization server, and required resource indicators ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html)) so a token issued for one server can't be replayed at another. [November 2025](https://modelcontextprotocol.io/specification/2025-11-25/changelog) filled in edges: OpenID Connect discovery, incremental scope consent over `WWW-Authenticate`, Client ID Metadata Documents. The [2026-07-28 spec](https://modelcontextprotocol.io/specification/2026-07-28/changelog), out at the end of July, hardened it again. A client now has to check the `iss` on an authorization response against the issuer it recorded, which is what stops a mix-up attack.

So auth in MCP is a specified thing now, not something you improvise. This post builds the smallest server that sits on the right side of it: a bearer token at the door, each user seeing only their own data, a 401 for anyone without a token. Then it proves that over `curl` rather than taking the SDK's word for it.

# Authentication and authorization

Before any code, two words worth keeping apart, because mixing them up is where this goes wrong.

**Authentication** answers *who is this?* A credential arrives, the server turns it into a known identity, and if it can't, the request stops there.

**Authorization** answers *what are they allowed to see?* It only means something once authentication has an answer, since you can't scope access for a caller you can't even name.

A wide-open server does neither. The one in this post does both, each in the smallest form that still counts.

# The door

Auth comes first because everything downstream leans on it: you turn a credential into a user before a single line of MCP logic runs. How you carry that credential depends on the transport, and the [authorization spec](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization) is explicit about which is which.

Over HTTP, MCP auth is OAuth 2.1. The client sends `Authorization: Bearer <token>` on every request, tokens are never allowed in the query string, and an invalid one gets a 401. Over stdio, where the server is a local subprocess the host launches, you do none of that; the spec says to read credentials from the environment instead. Anything else follows its own best practices. So the bearer token below is the HTTP-transport mechanism itself, and this server runs over HTTP.

That OAuth model splits the work across three parties. The MCP server is the *resource server*: it accepts an access token and validates it, but it never issues one. A separate *authorization server*, which the spec lets you co-host or run elsewhere, is what actually authenticates the user and issues the token; the client discovers where that server lives from the MCP server's protected-resource metadata. The MCP client is the *OAuth client*, obtaining the token on the user's behalf and attaching it to each request.

So a real server's job at the door is validation, not issuance: is this token authentic, was it issued for me as the intended audience, does it carry the scopes this request needs? This demo keeps the shape and drops the substance. There's no authorization server and no discovery; `authenticate` just maps a string to a user or returns null. The transport is what production uses. What I've stubbed out is the trust behind the token.

One thing to flag before the code, because it's the difference between "returns a 401" and "returns a 401 a client can do anything with". A conformant server puts a `WWW-Authenticate` header on that 401 pointing at its protected-resource metadata, per [RFC 9728 §5.1](https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response):

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",scope="sales:read"
```

That header is how the client finds the authorization server and starts the OAuth flow. Mine doesn't send one, because there's no authorization server here to point at. So the status code below is right and the challenge is missing, which is fine for a demo and not fine for anything real.

```ts

  id: string;
  name: string;
}

const TOKENS: Record<string, User> = {
  tok_alice: { id: "alice", name: "Alice" },
  tok_bob: { id: "bob", name: "Bob" },
};

  if (!authorization?.startsWith("Bearer ")) return null;
  return TOKENS[authorization.slice("Bearer ".length).trim()] ?? null;
}
```

In Express that becomes middleware in front of `/mcp`. No valid token, no MCP at all; you never reach the protocol.

```ts
app.use("/mcp", (req, res, next) => {
  const user = authenticate(req.headers.authorization);
  if (!user) {
    return res.status(401).json({
      jsonrpc: "2.0",
      error: { code: -32001, message: "Unauthorized" },
      id: null,
    });
  }
  req.user = user; // everything past here knows who's calling
  next();
});
```

The point of doing it as middleware, rather than a check inside each tool, is that the tools never see a token and never decide who's calling. That gets settled at the door, in one place, and there's no tool you can forget to wire up.

You wouldn't hand-write `authenticate` in production either. The MCP TypeScript SDK ships this exact middleware as [`requireBearerAuth`](https://ts.sdk.modelcontextprotocol.io/v2/functions/_modelcontextprotocol_express.auth_bearerAuth.requireBearerAuth.html). You hand it a token verifier, it checks the header, and it attaches the result to `req.auth`, which the transport then surfaces to your tools (as `ctx.http.authInfo` in SDK v2, where the middleware lives in `@modelcontextprotocol/express`; as `extra.authInfo` on v1, which is what this demo pins). It also sends the `WWW-Authenticate` challenge I skipped above, with your configured `resource_metadata` URL in it.

The verifier itself, the part that checks a JWT's signature, audience, issuer and expiry, is what a library like Auth0's `express-oauth2-jwt-bearer` handles. My `authenticate` is the teaching version of that whole stack: the same point in the request, with a map lookup where the real validation goes.

# Where identity lives

The obvious way to carry that user through is the session. Streamable HTTP hands back a session id on `initialize`, you keep the transport in a map keyed by that id, and follow-up requests carrying `mcp-session-id` land back on the same instance. Bind the user once at `initialize`, and every tool that server exposes is scoped to them for the life of the connection.

That's how I wrote it first. It leaks.

The middleware checks that a valid token is present. It never checks that the token belongs to the user the session was bound to. So initialize as Bob, keep his session id, then send Alice's perfectly good token with it:

```bash
curl -s -X POST :3003/mcp \
  -H "Authorization: Bearer tok_alice" \
  -H "mcp-session-id: 46462733-107f-4080-971b-112086c9f523" \
  -d '{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"get_sales","arguments":{}}}'
```

```json
{"content":[{"type":"text","text":"Bob's sales by region (totals)."}],
 "structuredContent":{"rows":[{"label":"APAC","value":1860},{"label":"LATAM","value":790}]}}
```

Alice authenticated fine. The server just stopped consulting her token the moment it had a session id to route by, and routing by session id means routing by whoever ran `initialize`. Session ids are UUIDv4 so nobody's guessing them, but they travel in a response header that CORS is usually happy to expose, and "unguessable" is a thin thing to rest an authorization boundary on.

You could patch it: stash `user.id` next to the transport and compare on every request. Deleting the session is better. Pass `sessionIdGenerator: undefined` and build a server per request instead.

```ts
app.post("/mcp", async (req, res) => {
  // A server and a transport for THIS request, scoped to the user who
  // authenticated THIS request.
  const server = createServer(req.user);
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });

  res.on("close", () => {
    transport.close().catch(() => {});
    server.close().catch(() => {});
  });

  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});
```

No session id is issued and none is expected back. Identity gets re-derived from the `Authorization` header on every request because there's nowhere else it could come from, which closes the gap between who authenticated and whose data comes back. GET and DELETE become 405s, since there's no long-lived stream to resume and no session to delete.

The protocol went the same way, harder. The [2026-07-28 revision](https://modelcontextprotocol.io/specification/2026-07-28/changelog) removed protocol-level sessions and the `Mcp-Session-Id` header (SEP-2567), then removed the `initialize` handshake along with them (SEP-2575); every request now carries its own protocol version and client capabilities in `_meta`. Servers needing cross-call state are told to mint explicit handles and pass them as ordinary tool arguments.

The SDK still speaks the 2025 session model for older clients, so either version of the code above runs today. One of them just happens to already be the shape the spec now asks for.

# Scoped tools

Because `createServer` is handed a user, its tools are closures over that user's data. The same code runs for everyone and returns different things, which is what authorization means here.

```ts
const OWNERSHIP: Record<string, string[]> = {
  alice: ["EMEA", "AMER"],
  bob: ["APAC", "LATAM"],
};

  const server = new McpServer({ name: "Sales Explorer", version: "1.0.0" });
  const owned = OWNERSHIP[user.id] ?? [];

  // Scope the enum to what this user owns, so the *schema* the model reads never
  // mentions regions they can't see.
  const regionSchema = owned.length ? z.enum(owned as [string, ...string[]]) : z.string();

  server.registerTool("get_sales", {
    inputSchema: { region: regionSchema.optional().describe("A region you own.") },
    /* ... */
  }, async ({ region }) => {
    if (region && !owned.includes(region)) {
      return { isError: true, content: [{ type: "text", text: `${user.name}, you don't have access to ${region}.` }] };
    }
    const rows = region ? monthly(region) : overviewRows(owned);
    return { content: [{ type: "text", text: `${user.name}'s sales.` }], structuredContent: { rows } };
  });

  return server;
}
```

Two layers there, and the schema one is the more interesting. `regionSchema` is built per user, so `tools/list` genuinely differs by token: Alice's `region` enum comes back as `["EMEA","AMER"]` and Bob's as `["APAC","LATAM"]`. The model never learns the names of regions its user can't reach, which matters because a static enum has the model cheerfully suggesting APAC to Alice all day even when her data is scoped.

The `owned.includes(region)` check is the backstop for any client working off a stale schema. Worth knowing what that means on the wire, though: a well-formed call from a current schema can't name APAC in the first place, so the friendly refusal string is rarer than you'd think. More on that in a second.

The store behind all this is an in-memory map so the demo needs no database. Point it at Redis or Postgres and nothing above it changes.

# Proving it on the wire

I don't trust a server because the code looks right; I trust it when the bytes agree. Here's the whole thing against a running server: no token turned away, each user seeing only their own regions, and a region you don't own refused.

Read the Alice and Bob lines together: same `get_sales`, same code path, different data, because the token on the request resolved to a different user. That's the line a wide-open server can't produce.

The refusal is worth a second look, since it's not the string the handler would have printed. Alice's schema only offers EMEA and AMER, so `{"region":"APAC"}` dies at input validation with a `-32602` before `owned.includes` ever runs. Her `"Alice, you don't have access to APAC."` message only shows up for a client calling off a stale schema. Both layers work, and the outer one gets there first.

Notice too what's missing from that transcript: any `mcp-session-id`. Every one of those calls carries its own token and nothing else.

It holds up in a real host, not just `curl`. Point [Goose](/blog/mcp-servers-in-goose) at it with `Authorization: Bearer tok_alice` (its remote extensions take a `headers` block, and Goose sends it on every request) and `get_sales` returns Alice's two regions and nothing else, the same authenticated call `curl` made.

# Authenticating an MCP App

The first post's [Sales Explorer](/blog/build-an-mcp-app-interactive-ui-in-the-chat) had the opposite problem to this one: a real interactive chart, but wide open. Put the two together and you get the case worth building, an MCP App whose chart is scoped to the person looking at it.

The tempting way to describe that is "the app knows who opened it." That's wrong, and getting it straight is the whole point. The app authenticates no one. Identity rides on the request. Alice configures her host with her token, Bob configures his with his, and the host attaches that token to every call it makes on their behalf, including the ones the chart triggers. So when Alice's chart calls `get_sales`, the server sees Alice because her token was on that request, not because the app worked out who was clicking.

The sandbox makes this cleaner than it sounds. MCP Apps run in sandboxed iframes with no access to the host's DOM, cookies or storage, talking back only over postMessage, so the app can't read the token even in principle. It calls tools and gets back data already narrowed to whoever's token the host is sending. Drill into a region you don't own and the callback is refused exactly like a direct call, because it is exactly a direct call.

In code this is the same `createServer` from above with two swaps. `registerTool` becomes `registerAppTool` carrying a `_meta.ui.resourceUri`, and a matching `ui://` resource serves the bundled chart. The ownership guard and the scoped enum don't change at all:

```ts
const resourceUri = "ui://sales-explorer/mcp-app.html";

registerAppTool(server, "get_sales", {
  inputSchema: { region: regionSchema.optional() },   // still scoped to `owned`
  outputSchema: SalesPayload.shape,
  _meta: { ui: { resourceUri } },                     // <- this makes it an MCP App
}, async ({ region }) => { /* the same guard as before */ });

registerAppResource(server, resourceUri, resourceUri, { mimeType: RESOURCE_MIME_TYPE },
  async () => ({ contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }] }));
```

Alice opens it and sees a two-bar chart for EMEA and AMER; Bob opens the same app and sees APAC and LATAM. Same server, same UI bundle, different chart, decided by the token on the request.

![Alice's authenticated Sales Explorer in Goose, showing only her two regions, EMEA and AMER](https://res.cloudinary.com/tamas/image/upload/f_auto,q_auto,w_900/v1783072251/goose1_m0l1x9.png)

The app fetches its own overview on connect rather than waiting for the host to push the tool result, so the chart renders in Goose. Drilling into a bar calls `get_sales` again, carrying the same token as everything else, and redraws in place with no model turn.

![Drilling into AMER redraws its monthly breakdown in place, with no model turn](https://res.cloudinary.com/tamas/image/upload/f_auto,q_auto,w_900/v1783072251/goose2_arcavu.png)

The full thing is in [`auth/`](https://github.com/tpiros/mcp-apps/tree/main/auth), alongside the wide-open version from the first post at the repo root. Same app twice, so you can diff them.

# The sharp edges

A few things this small version gets right, and some it skips on purpose.

The auth check is middleware, so there's no tool you can forget to protect, and because nothing is cached against a session there's no request that gets to skip it. The origin check in front of that returns a 403 for any browser origin not on the allowlist, which is the DNS-rebinding guard the transport spec asks for. Default `cors()` answers everything with `*`, so this is one of the easier things to get wrong by writing less code.

What it skips is the entire authorization server: real token issuance, audience validation ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html) resource indicators on the client side, the `aud` check on mine), rotation, and secret storage. `TOKENS` is a literal so the moving parts stay visible. Downstream of that, the 401 goes out without the `WWW-Authenticate` challenge a conformant server owes its clients, because there's no metadata document to advertise. That's the line between "smallest honest version" and "production", and it's worth saying out loud before anyone pastes a hardcoded map into something that matters.

The rest is the real shape. A server that makes every caller prove who they are, on every request, and shows each of them only their own data, is the same tool registration from the first post with a door in front of it. The spec tells you how to build that door, and since 28 July it also tells you not to keep a session behind it.
