Tutorial // MCP2026-06-1713 min read

Build an OAuth-Secured MCP Server

MCP crossed 200 server implementations and now supports OAuth. Here's how to build an MCP server that authenticates callers instead of trusting everyone.

V
Varun Raj ManoharanFounder & Principal Engineer
MCPOAuthSecurityTypeScriptTutorial

Key takeaways

  • An OAuth-secured MCP server acts as a resource server: it validates bearer tokens but never logs anyone in or issues tokens itself.
  • Validating a token's audience claim is the most important check, because a well-signed token minted for another service can otherwise be replayed against you.
  • Keep the bearer middleware at the route level so the initialize handshake and tools/list response are gated and your schema stays hidden from unauthenticated callers.
  • Route-level scopes guard the door but not the data; tool bodies must still constrain queries by client id or tenant claim across tenants.

The Model Context Protocol stopped being a curiosity sometime this spring. The public registry now lists more than 200 server implementations, and the ones people actually run are no longer toy examples that read a local file. They query production databases, hit internal APIs, and trigger actions that cost money or move data. That shift changes the threat model completely. A stdio server that runs as a subprocess of your own editor is one thing. A server you expose over HTTP so a hosted assistant can reach it is a different animal, and the default posture of "anyone who can open the socket gets every tool" is no longer acceptable.

For a while the honest answer to "how do I secure a remote MCP server" was "you sort of bolt something on the side and hope." That gap is closed now. The MCP specification defines an authorization flow built on OAuth 2.1, and the TypeScript SDK ships the middleware to enforce it. The model is the standard one for OAuth resource servers: your MCP server does not log anyone in and does not issue tokens. It accepts a bearer token, checks that the token was actually minted for it, checks the scopes, and only then lets a tool run. Login and consent live in a separate authorization server, which can be something you host or an existing identity provider.

This tutorial builds a small but real example. We will create an MCP server with a couple of tools, put it behind the Streamable HTTP transport, advertise where the authorization server lives, and validate every incoming token before any tool executes. I verified the API below against the MCP authorization spec (revision 2025-06-18) and version 1.29.0 of @modelcontextprotocol/sdk. The auth surface in this SDK has moved around across releases, so if you are on a different version, check the exported names before copying anything wholesale.

What you'll need

  • Node 20 or newer. The SDK uses modern ESM and the web crypto API.
  • The MCP TypeScript SDK and Express. The SDK's HTTP transport and auth middleware are written against Express, so it is the path of least resistance.
  • An OAuth 2.1 authorization server you can point at. For local development that can be a small dev instance of something like Keycloak, Ory Hydra, Auth0, or your own. What matters is that it issues access tokens with an audience that identifies your MCP server, and that it publishes standard metadata at /.well-known/oauth-authorization-server.
  • A way to validate tokens. If your tokens are JWTs you can verify them locally against the authorization server's JWKS. If they are opaque, you will call the introspection endpoint instead. We will write the verifier as a plain interface so either approach drops in.
Shell
npm install @modelcontextprotocol/sdk express zod
npm install -D typescript tsx @types/node @types/express

zod is used for tool input schemas. tsx lets us run the TypeScript directly while iterating.

Start with the server and its tools

Before any auth, get a working server. The McpServer class is the high-level API, and registerTool is how you attach a tool. The import paths use subpath exports, so they look a little verbose, but they are exactly what the package publishes.

TypeScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

export function buildServer() {
  const server = new McpServer({
    name: "billing-tools",
    version: "1.0.0",
  });

  server.registerTool(
    "lookup_invoice",
    {
      title: "Look up an invoice",
      description: "Return the status and total for a single invoice by id.",
      inputSchema: { invoiceId: z.string().describe("The invoice id, e.g. INV-2041") },
    },
    async ({ invoiceId }, extra) => {
      const invoice = await db.invoices.find(invoiceId);
      if (!invoice) {
        return {
          content: [{ type: "text", text: `No invoice found for ${invoiceId}.` }],
          isError: true,
        };
      }
      return {
        content: [
          { type: "text", text: `${invoice.id}: ${invoice.status}, total ${invoice.total}` },
        ],
      };
    }
  );

  return server;
}

Two details that trip people up. First, inputSchema is a raw shape, the plain object of Zod validators, not z.object(...) wrapping them. The SDK builds the object for you. Second, the handler gets a second argument I have named extra. That is the request context, and it is where the validated token information will show up once auth is wired in. Right now it carries nothing useful because nothing is checking tokens yet. Hold that thought.

Put it behind Streamable HTTP

stdio is fine for a server that lives inside one user's machine, but a server other people connect to over the network needs an HTTP transport, and that is also the only transport the authorization spec applies to. The current transport is StreamableHTTPServerTransport. Here is a minimal Express wiring with no auth yet, so you can confirm the plumbing works.

TypeScript
import express from "express";
import { randomUUID } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { buildServer } from "./server.js";

const app = express();
app.use(express.json());

app.post("/mcp", async (req, res) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: () => randomUUID(),
    enableDnsRebindingProtection: true,
    allowedHosts: ["127.0.0.1:3000", "mcp.yourdomain.com"],
  });
  const server = buildServer();
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

app.listen(3000, () => console.log("MCP server on http://127.0.0.1:3000/mcp"));

I turned on DNS rebinding protection and set an allow list of hosts. This is not optional theater. Without host validation, a web page the victim visits can make their browser POST to your localhost server and reach tools that were never meant to face the open web. The SDK gives you the switch, so use it. For a server bound to a public hostname, the allow list is your hostname, and for local development it is the loopback address and port.

Creating a fresh server and transport per request keeps this example readable. A production server usually wants session reuse keyed off the Mcp-Session-Id header, and the SDK supports that, but session handling is orthogonal to auth and would double the length of every snippet here. Get auth right on the simple version first.

Here is where MCP's flow gets specific. Your server is a resource server in OAuth terms. It does not run a login page. What it must do, per the spec, is implement OAuth 2.0 Protected Resource Metadata (RFC 9728) so that a client which shows up without a token can discover where to go get one. The client makes an unauthenticated request, gets a 401 with a WWW-Authenticate header pointing at your metadata document, reads the metadata, finds the authorization server, and runs the OAuth dance over there.

The SDK has a router for the metadata document. You give it the metadata of the authorization server you rely on and the URL of your own resource, and it mounts the well-known endpoint.

TypeScript
import { mcpAuthMetadataRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";

const RESOURCE_URL = new URL("https://mcp.yourdomain.com/mcp");
const AUTH_SERVER = "https://auth.yourdomain.com";

app.use(
  mcpAuthMetadataRouter({
    // The metadata document of the authorization server you trust.
    oauthMetadata: {
      issuer: AUTH_SERVER,
      authorization_endpoint: `${AUTH_SERVER}/authorize`,
      token_endpoint: `${AUTH_SERVER}/token`,
      response_types_supported: ["code"],
    },
    resourceServerUrl: RESOURCE_URL,
    scopesSupported: ["invoices:read", "invoices:write"],
    resourceName: "Billing Tools MCP",
  })
);

oauthMetadata is the authorization server's own metadata, the same shape it serves at its /.well-known/oauth-authorization-server endpoint. In a lot of setups you would fetch that once at startup rather than hardcode it, so a rotated endpoint does not silently break you. resourceServerUrl is the canonical URI of this MCP server, and it has to match what clients will use as the OAuth resource parameter. The spec is strict about this URI. No fragment, and prefer the form without a trailing slash.

Validate tokens before any tool runs

Discovery tells an honest client where to authenticate. It does nothing to stop a dishonest one. The actual enforcement is the requireBearerAuth middleware, and the heart of it is a verifier you supply. The verifier takes the raw token string and returns the token's claims, or throws if the token is no good.

TypeScript
import type { OAuthTokenVerifier } from "@modelcontextprotocol/sdk/server/auth/provider.js";
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(new URL(`${AUTH_SERVER}/.well-known/jwks.json`));

const verifier: OAuthTokenVerifier = {
  async verifyAccessToken(token: string): Promise<AuthInfo> {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: AUTH_SERVER,
      audience: RESOURCE_URL.href,
    });

    return {
      token,
      clientId: String(payload.client_id ?? payload.azp ?? ""),
      scopes: typeof payload.scope === "string" ? payload.scope.split(" ") : [],
      expiresAt: payload.exp,
      resource: RESOURCE_URL,
    };
  },
};

Notice the audience: RESOURCE_URL.href check inside jwtVerify. That is not a nicety. It is the single most important line in this file. A token issued for some other service must not be accepted here, even if it was signed by the same authorization server and even if it looks otherwise valid. Without the audience check, an attacker who holds a legitimate token for a different resource can replay it against your server, and jose will happily say the signature is fine. The spec calls this out directly: a server must reject any token that does not name it in the audience claim. If your tokens are opaque rather than JWTs, swap the body of verifyAccessToken for a call to the authorization server's introspection endpoint, and check the returned aud and active fields with the same suspicion.

Now mount the middleware in front of the MCP route. The resourceMetadataUrl option is what gets advertised in the WWW-Authenticate header on a 401, closing the discovery loop from earlier.

TypeScript
import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
import { getOAuthProtectedResourceMetadataUrl } from "@modelcontextprotocol/sdk/server/auth/router.js";

const bearer = requireBearerAuth({
  verifier,
  requiredScopes: ["invoices:read"],
  resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(RESOURCE_URL),
});

app.post("/mcp", bearer, async (req, res) => {
  const transport = new StreamableHTTPServerTransport({
    sessionIdGenerator: () => randomUUID(),
    enableDnsRebindingProtection: true,
    allowedHosts: ["mcp.yourdomain.com"],
  });
  const server = buildServer();
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

With this in place the middleware does three things on every request. It pulls the bearer token from the Authorization header, runs your verifier, and on success attaches the resulting AuthInfo to req.auth. A missing or invalid token gets a 401 with the WWW-Authenticate header. A valid token that lacks the requiredScopes gets a 403. The request never reaches your tool code in either failure case.

The token information also flows through to the tool handler. Remember that extra argument from the first snippet? After auth, extra.authInfo holds the same AuthInfo object, which means a tool can make finer-grained decisions than the coarse route-level scope check.

TypeScript
server.registerTool(
  "void_invoice",
  {
    title: "Void an invoice",
    description: "Mark an invoice as voided. Requires write access.",
    inputSchema: { invoiceId: z.string() },
  },
  async ({ invoiceId }, extra) => {
    const scopes = extra.authInfo?.scopes ?? [];
    if (!scopes.includes("invoices:write")) {
      return {
        content: [{ type: "text", text: "This action requires invoices:write." }],
        isError: true,
      };
    }
    await db.invoices.void(invoiceId, { actor: extra.authInfo?.clientId });
    return { content: [{ type: "text", text: `Voided ${invoiceId}.` }] };
  }
);

Reading access can ride on the route-level invoices:read scope. A destructive tool like voiding an invoice asks for invoices:write itself, so a token that was only granted read access can list invoices but cannot void one. You also get the calling client's id for free, which is what you want in your audit log when someone asks who voided invoice 2041.

Gotchas

A few things that bite people, roughly in order of how much damage they do.

Audience validation is the whole game. I said it above and I am saying it again because skipping it is the failure I see most. Checking that a token is signed and unexpired is not enough. You have to check that the token was issued for your server specifically. A server that accepts any well-signed token from the authorization server is one cross-service token replay away from a breach, and nothing about the request will look wrong in your logs.

Scopes are not a substitute for in-tool authorization over data. A scope like invoices:read says the caller may read invoices. It says nothing about which invoices. If your invoices belong to different tenants, the tool body still has to constrain the query to the rows this caller is allowed to see, using the client id or a tenant claim from authInfo. The middleware guards the door. It does not partition the room.

Do not leak your schema to unauthenticated callers. The tool list itself is information. It tells an attacker that you have a void_invoice tool and what it takes. Because the bearer middleware sits in front of the whole /mcp route, the initialize handshake and the tools/list response are both gated, which is what you want. If you ever move auth to a per-tool check inside handlers instead of route-level middleware, you reopen this hole, because listing happens before any handler runs. Keep the gate at the transport boundary.

Mind which errors you return and how detailed they are. A 401 should mean "authenticate," a 403 should mean "you authenticated but lack permission," and a 400 should mean "your request was malformed." The SDK middleware gets these right, but if you add your own checks, resist the urge to explain too much in the error body. "Invalid token" is enough. "Token expired at 14:02 for audience https://internal-api" hands a prober a map.

Transport choice carries security weight, not just ergonomics. The authorization flow only applies to HTTP transports. If you expose stdio, credentials come from the environment and none of this applies, which is correct for a local subprocess but wrong the moment that server is reachable over a network. And whatever you do over HTTP must be HTTPS in anything but local development. OAuth 2.1 requires it, redirect URIs have to be HTTPS or localhost, and a bearer token sent over plain HTTP is a bearer token you have given away.

Wrapping up

The pieces here are deliberately small: a server with two tools, a metadata router so clients can discover where to authenticate, a verifier that checks signatures and audience, and one line of middleware that ties it to the route. That is a complete OAuth-secured MCP server, and most of the security lives in choices that are easy to get wrong quietly, the audience check above all.

What I would not do is treat this as the finish line. Real deployments need session handling, token caching that respects expiry, rate limits, and a serious answer for how the upstream APIs your tools call get their own credentials, which is a separate token from the one the client gave you and must never be the same one passed through. But the authentication boundary is the part you cannot retrofit safely after the fact, and now that the spec and the SDK agree on how it works, there is no excuse to ship a remote MCP server that trusts everyone who can find the URL.