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 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 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) so a token issued for one server can’t be replayed at another. November 2025 filled in edges: OpenID Connect discovery, incremental scope consent over WWW-Authenticate, Client ID Metadata Documents. The 2026-07-28 spec, 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 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:
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.
export interface User {
id: string;
name: string;
}
const TOKENS: Record<string, User> = {
tok_alice: { id: "alice", name: "Alice" },
tok_bob: { id: "bob", name: "Bob" },
};
export function authenticate(authorization?: string): User | null {
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.
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. 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:
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":{}}}'
{"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.
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 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.
const OWNERSHIP: Record<string, string[]> = {
alice: ["EMEA", "AMER"],
bob: ["APAC", "LATAM"],
};
export function createServer(user: User): McpServer {
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 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 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:
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.

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.

The full thing is in 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 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.