MCP server authentication: OAuth, scopes and rate limits
9m read time

MCP server authentication: OAuth, scopes and rate limits

You built an MCP server. The tutorial ended where security starts. How to add OAuth, scoped tokens and rate limits to your own server, with TypeScript and Laravel examples, so it does not join the 12,500 open ones.

Every MCP tutorial ends at the same spot. The server runs, the agent calls the tool, the demo works. Mine ended there too. Twenty lines of FastMCP, a weather forecast, done.

That ending is fine, right up until the server leaves your machine. The moment it listens on a real network interface, you have joined a queue you do not want to be in. Censys counted 12,520 internet-facing MCP servers in late April, and roughly 40% of them accept requests from anyone.

I wrote about what that exposure means from the attacker's side. This post is the builder's side: what authentication on your own MCP server actually looks like, in TypeScript and in Laravel, before someone else finds the port.

When you don't need OAuth at all

Start with the case the spec itself carves out, because it saves a lot of people a lot of work.

If your server runs over stdio, meaning the client launches it as a local process and talks over standard in and out, the MCP spec says you should not implement OAuth. There is no network boundary to defend. Credentials the server needs, like an API key for the weather service it wraps, come in through environment variables. The trust boundary is your machine, and your operating system already guards that.

The same logic covers a server bound to localhost for your own use. Adding an OAuth flow there is ceremony without a threat model.

So the first question is the one from the build post: does this server need to be remote at all? If the agent and the server share a machine, keep them on stdio and stop reading here. Everything below is for the moment the answer is genuinely yes, a teammate needs it, another service needs it, or you are hosting it for others.

Going remote: your server is a resource server

Here is the mental model that makes the MCP authorization spec click. In OAuth 2.1 terms your server is a resource server, and only that. Logins, consent screens, password storage and token issuance all belong to a separate system, the authorization server, which owns identity. That can be Auth0, Keycloak, WorkOS, or your existing Laravel app with Passport.

Nine of the 67 CVEs that came out of the VIPER-MCP scan of roughly 40,000 server repositories traced back to hand-rolled OAuth flows. The servers that tried to be their own identity provider introduced the holes. The spec's split exists to keep you off that list: let a boring, audited system do identity, and give your server three narrow jobs.

Job one: tell clients where to log in. When a request arrives without a valid token, respond with 401 and a WWW-Authenticate header pointing at your protected resource metadata, a small JSON document defined by RFC 9728 that you serve at /.well-known/oauth-protected-resource. It names your authorization server and the scopes you support. A well-behaved MCP client, and Claude Code is one, reads that, runs the OAuth flow with the authorization server it names, and comes back with a token. Discovery is automatic: you publish one JSON file and one header.

Job two: validate the token, including who it was issued for. Signature and expiry checks are the obvious part. The one people skip is audience. RFC 8707 has clients bind each token to the specific server it is meant for, and your server must reject tokens whose audience is anything else. Without that check, a token stolen from some other service works on yours, and a token issued for yours works elsewhere.

The spec is blunt about the related sin: never take a token you received and pass it through to an upstream API. Your server exchanges or holds its own credentials for upstream calls.

Job three: gate tools by scope. A valid token is a yes to the door, not to every room. More on that below, because it is where most real deployments are thinnest.

That is the whole architecture. Now the code.

TypeScript: the SDK does the ceremony

The official TypeScript SDK ships the middleware for jobs one and two. If your server is Express-based with the streamable HTTP transport, auth is a few declarations:

typescript
import express from 'express';
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
import { verifier } from './verifier.js';

const app = express();

app.use('/mcp', requireBearerAuth({
  verifier,
  requiredScopes: ['weather:read'],
  resourceMetadataUrl:
    'https://weather.example.com/.well-known/oauth-protected-resource',
}));

The verifier is the one part you write, and it is small. It receives the raw Bearer token and returns what the SDK calls AuthInfo: the client id, the granted scopes, the expiry. For a JWT from your authorization server, that is a signature check, an expiry check, and the audience check from job two:

typescript
import { createRemoteJWKSet, jwtVerify } from 'jose';

const jwks = createRemoteJWKSet(
  new URL('https://auth.example.com/.well-known/jwks.json'),
);

export const verifier = {
  async verifyAccessToken(token: string) {
    const { payload } = await jwtVerify(token, jwks, {
      issuer: 'https://auth.example.com',
      audience: 'https://weather.example.com/mcp',
    });
    return {
      token,
      clientId: String(payload.client_id),
      scopes: String(payload.scope ?? '').split(' '),
      expiresAt: payload.exp,
    };
  },
};

The audience line is the RFC 8707 check. Delete it and the code still runs, every demo still passes, and any token from the same identity provider now opens your server. It is the least visible line here and the most important one.

Everything else falls out of the middleware. Requests without a token get the 401 with the WWW-Authenticate challenge from job one. Valid requests reach your handlers with req.auth populated, so tools can inspect scopes. You serve the RFC 9728 metadata document itself as a static JSON route, five lines naming your authorization server and scopes.

Laravel: one route file, one middleware

The official laravel/mcp package leans on infrastructure Laravel developers already run. If your app uses Passport, the whole thing lives in routes/ai.php:

php
use App\Mcp\Servers\WeatherExample;
use Laravel\Mcp\Facades\Mcp;

Mcp::oauthRoutes();

Mcp::web('/mcp/weather', WeatherExample::class)
    ->middleware('auth:api');

Mcp::oauthRoutes() registers the discovery endpoints, including the protected resource metadata, and wires client registration so MCP clients can onboard themselves. The auth:api middleware is plain Passport token validation, the same guard protecting the rest of your API. Your existing Passport setup is the authorization server, your MCP endpoint is the resource server, and the split from the spec maps onto pieces you already operate.

If you run Sanctum instead, the honest shortcut is ->middleware('auth:sanctum') with a personal access token pasted into the client config. That works today and beats an open port by miles.

Be clear about what you are trading, though: no discovery, no expiry unless you set one, and a long-lived static credential, which is exactly the pattern the exposure numbers flag. Among internet-facing MCP servers that have any authentication, 53% rely on a single static API key. Sanctum-with-a-pasted-token is a tidier cousin of that. Fine for two teammates, wrong for anything bigger, and Laravel's own docs point to Passport for that reason.

Whichever framework, the client side comes free. Point Claude Code at the URL and it discovers the metadata, opens the browser, and stores the token. I walked through that flow from the other end in logging in to MCP servers from your shell. Building the server side to spec is what makes that thirty-second login work.

Scopes: one verb, one scope

A token that unlocks everything is a static API key with better marketing. The 53% figure exists because one all-access credential is the path of least resistance, and rebuilding that pattern inside OAuth wastes the whole exercise.

The discipline is one scope per verb of risk. The weather server has one read tool, so weather:read is plenty. A server that reads and writes gets weather:read and weather:write, and a destructive admin tool gets its own scope that ordinary clients never request. In the TypeScript example, requiredScopes gates the transport, and individual tools can check req.auth.scopes for finer cuts. In Laravel, Passport's scope middleware does the same per route.

When a token is valid but under-scoped, the spec has a specific answer: respond 403 with insufficient_scope in the WWW-Authenticate header, naming the scopes the operation needs. Clients that support step-up authorization can send the user back for exactly the missing grant. The user approves the dangerous scope the day a tool needs it, instead of pre-approving everything on day one.

The payoff shows up when a token leaks, and with agents pasting configs into dotfiles and CI logs, assume one eventually will. A leaked weather:read token reads forecasts until it expires. A leaked all-access token is the master key incident from the exposure post.

Rate limits: agents retry harder than people

Authentication decides who gets in. Rate limits decide how hard they can hammer once inside, and MCP clients are agents, which changes the arithmetic. A person retries a failing call three times and gives up. An agent in a loop retries until its own limits kick in, and an agent with a stolen token enumerates your tool list methodically at machine speed.

Rate limit per token, per tool. The per-token part means one noisy client cannot starve the rest and gives you a per-identity dial to turn down. The per-tool part reflects that your tools carry different price tags: a cheap read can afford hundreds of calls a minute, a tool that sends email or writes to production should be capped so low that a runaway loop dies on the second lap.

In Laravel this is the throttle middleware keyed on the token, stacked right after auth:api. In Express it is one of the standard limiter packages keyed on req.auth.clientId instead of IP, because agent traffic behind a NAT makes IP-keyed limits both leaky and unfair. Return 429 with a Retry-After header. Well-built agents respect it, and the ones that do not are telling you which tokens to revoke.

Rate limits are also your cheapest incident response. Token compromised at 3 a.m.? The blast radius is whatever the ceiling allows before revocation, rather than everything the tools can do at full speed.

The checklist

The build post asked whether your server should exist. This one assumes it should, and asks whether it is dressed for the internet:

  • Still on one machine? Stay on stdio, pass credentials by environment variable, skip OAuth entirely.
  • Going remote? Be a resource server only. Identity belongs to an authorization server you did not write.
  • Serve the metadata. RFC 9728 document at the well-known URI, 401 plus WWW-Authenticate pointing at it.
  • Check the audience. Reject tokens issued for anything that is not your server, and never pass an inbound token upstream.
  • Scope per verb. Read is not write, and neither is delete. Answer under-scoped calls with 403 insufficient_scope.
  • Rate limit per token, per tool. Price each tool by its blast radius, then vet the servers you consume by the same standards you now build to.

All of it is ordinary plumbing: a JSON document, a header, a token check with one extra claim, and middleware your framework already ships. A weekend of work, most of it configuration, separates your server from the 12,500 that skipped it.

The tutorial got you a server that works. This is the part that makes it yours alone.

(21 of 21)