Elke MCP-tutorial eindigt op hetzelfde punt. De server draait, de agent roept de tool aan, de demo werkt. De mijne eindigde daar ook. Twintig regels FastMCP, een weerbericht, klaar.
Dat einde is prima, tot het moment dat de server je machine verlaat. Zodra hij op een echte netwerkinterface luistert, sta je ineens op een lijst waar je niet op wilt staan. Censys telde eind april 12.520 MCP-servers aan het open internet, en ruwweg 40% daarvan accepteert verzoeken van iedereen. Over wat die blootstelling betekent schreef ik eerder, door de ogen van de aanvaller. Dit is de kant van de bouwer: hoe authenticatie op je eigen MCP-server er echt uitziet, in TypeScript en in Laravel, voordat iemand anders de poort vindt.
Wanneer je helemaal geen OAuth nodig hebt
Begin bij de uitzondering die de spec zelf maakt, want die scheelt veel mensen veel werk.
Draait je server over stdio, dus de client start hem als lokaal proces en praat via stdin en stdout, dan zegt de MCP-spec dat je juist geen OAuth moet implementeren. Er is geen netwerkgrens om te verdedigen. Credentials die de server nodig heeft, zoals een API-key voor de weerdienst waar hij omheen gebouwd is, komen binnen via environment variables. De vertrouwensgrens is je machine, en je besturingssysteem bewaakt die al.
Dezelfde logica geldt voor een server die op localhost draait voor eigen gebruik. Een OAuth-flow toevoegen is daar een ritueel zonder dreigingsmodel.
De eerste vraag is dus dezelfde als in het bouwartikel: moet deze server überhaupt remote? Delen agent en server een machine, hou het dan bij stdio en stop hier met lezen. Alles hieronder is voor het moment dat het antwoord echt ja is, omdat een collega erbij moet, een andere dienst erbij moet, of omdat je hem voor anderen host.
Remote gaan: je server is een resource server
Dit is het denkmodel waarmee de MCP-autorisatiespec op zijn plek valt. In OAuth 2.1-termen is je server een resource server, en alleen dat. Inlogschermen, wachtwoordopslag en het uitgeven van tokens horen allemaal bij een apart systeem, de authorization server, dat identiteit regelt. Dat kan Auth0 zijn, Keycloak, WorkOS, of je bestaande Laravel-app met Passport.
Negen van de 67 CVE's uit de VIPER-MCP-scan van zo'n 40.000 server-repository's kwamen voort uit zelfgebouwde OAuth-flows. Juist de servers die hun eigen identity provider probeerden te zijn, introduceerden de gaten. De splitsing in de spec bestaat om jou van die lijst af te houden: laat een saai, doorgelicht systeem identiteit regelen, en geef je server drie afgebakende taken.
Taak één: vertel clients waar ze kunnen inloggen. Komt er een verzoek binnen zonder geldig token, antwoord dan met 401 en een WWW-Authenticate-header die wijst naar je protected resource metadata. Dat is een klein JSON-document uit RFC 9728 dat je serveert op /.well-known/oauth-protected-resource, met daarin je authorization server en de scopes die je ondersteunt. Een nette MCP-client, en Claude Code is er zo eentje, leest dat, doorloopt de OAuth-flow bij die authorization server en komt terug met een token. Discovery gaat vanzelf. Jij publiceert één JSON-bestand en één header.
Taak twee: valideer het token, inclusief voor wie het is uitgegeven. Handtekening en vervaltijd controleren is het voor de hand liggende deel. Wat mensen overslaan is de audience. RFC 8707 laat clients elk token binden aan de specifieke server waarvoor het bedoeld is, en jouw server moet tokens weigeren waarvan de audience iets anders is. Zonder die check werkt een token dat bij een andere dienst is gestolen ook bij jou, en werkt een token voor jouw server ook elders. Over de verwante zonde laat de spec geen twijfel bestaan: stuur een ontvangen token nooit door naar een upstream-API. Je server gebruikt eigen credentials voor calls richting upstream.
Taak drie: bewaak tools met scopes. Een geldig token opent de voordeur, niet elke kamer erachter. Daarover verderop meer, want daar zijn de meeste echte deployments het zwakst.
Dat is de hele architectuur. Nu de code.
TypeScript: de SDK regelt de plichtplegingen
De officiële TypeScript-SDK levert de middleware voor taak één en twee kant-en-klaar. Draait je server op Express met het streamable HTTP-transport, dan is auth een kwestie van een paar declaraties:
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',
}));De verifier is het enige deel dat je zelf schrijft, en het is klein. Hij krijgt het kale Bearer-token en geeft terug wat de SDK AuthInfo noemt: de client-id, de toegekende scopes, de vervaltijd. Voor een JWT van je authorization server komt dat neer op een handtekeningcheck, een vervalcheck en de audience-check uit taak twee:
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,
};
},
};De regel met audience is de RFC 8707-check. Haal hem weg en de code draait nog steeds, elke demo slaagt nog steeds, en elk token van dezelfde identity provider opent voortaan jouw server. Het is de minst zichtbare regel hier en de belangrijkste.
De rest volgt uit de middleware. Verzoeken zonder token krijgen de 401 met de WWW-Authenticate-challenge uit taak één. Geldige verzoeken bereiken je handlers met een gevulde req.auth, zodat tools scopes kunnen inspecteren. Het RFC 9728-metadatadocument zelf serveer je als statische JSON-route, vijf regels met je authorization server en je scopes.
Laravel: één routebestand, één middleware
Het officiële laravel/mcp-pakket steunt op infrastructuur die Laravel-ontwikkelaars al draaien. Gebruikt je app Passport, dan past het geheel in routes/ai.php:
use App\Mcp\Servers\WeatherExample;
use Laravel\Mcp\Facades\Mcp;
Mcp::oauthRoutes();
Mcp::web('/mcp/weather', WeatherExample::class)
->middleware('auth:api');Mcp::oauthRoutes() registreert de discovery-endpoints, inclusief de protected resource metadata, en regelt clientregistratie zodat MCP-clients zichzelf kunnen aanmelden. De auth:api-middleware is gewone Passport-tokenvalidatie, dezelfde guard die de rest van je API beschermt. Je bestaande Passport-setup is de authorization server, je MCP-endpoint is de resource server, en de splitsing uit de spec valt precies samen met onderdelen die je al beheert.
Draai je Sanctum, dan is de eerlijke sluiproute ->middleware('auth:sanctum') met een personal access token geplakt in de clientconfig. Dat werkt meteen en is nog altijd stukken beter dan een open poort. Wees wel duidelijk over wat je inlevert: geen discovery, geen vervaltijd tenzij je die instelt, en een langlevend statisch credential, precies het patroon waar de cijfers voor waarschuwen. Van de MCP-servers die aan het internet hangen en überhaupt authenticatie hebben, steunt 53% op één statische API-key. Sanctum met een geplakt token is daar het nettere broertje van. Prima voor twee collega's, verkeerd voor alles wat groter is, en Laravels eigen documentatie wijst om precies die reden naar Passport.
Welk framework je ook kiest, de clientkant krijg je cadeau. Wijs Claude Code naar de URL en hij vindt de metadata, opent de browser en bewaart het token. Die flow beschreef ik vanaf de andere kant in inloggen op MCP-servers vanuit je shell. De serverkant volgens de spec bouwen is wat die login van dertig seconden mogelijk maakt.
Scopes: één werkwoord, één scope
Een token dat alles opent is een statische API-key met betere marketing. Dat cijfer van 53% bestaat omdat één credential voor alles de weg van de minste weerstand is, en als je datzelfde patroon binnen OAuth nabouwt, had je de hele flow net zo goed kunnen overslaan.
Het devies is één scope per riskant werkwoord. De weerserver heeft één leestool, dus weather:read volstaat. Een server die leest en schrijft krijgt weather:read en weather:write. Een destructieve admin-tool krijgt een eigen scope die gewone clients nooit aanvragen. In het TypeScript-voorbeeld bewaakt requiredScopes het transport, en losse tools kunnen req.auth.scopes controleren voor fijnmaziger onderscheid. In Laravel doet de scope-middleware van Passport hetzelfde per route.
Is een token geldig maar te krap gescoped, dan heeft de spec daar een specifiek antwoord op: reageer met 403 en insufficient_scope in de WWW-Authenticate-header, met daarin de scopes die de operatie nodig heeft. Clients die step-up authorization ondersteunen sturen de gebruiker dan terug voor precies de ontbrekende toestemming. De gebruiker keurt de gevaarlijke scope goed op de dag dat een tool hem nodig heeft, in plaats van alles vooraf op dag één.
De winst zie je op de dag dat een token lekt, en met agents die configs in dotfiles en CI-logs plakken kun je ervan uitgaan dat dat ooit gebeurt. Een gelekt weather:read-token leest weerberichten tot het verloopt. Een gelekt alleskunner-token is het loper-incident uit het artikel over blootstelling.
Rate limits: agents geven niet op
Authenticatie bepaalt wie binnenkomt. Het zegt niets over hoe hard iemand daarbinnen tekeer mag gaan, en MCP-clients zijn agents, wat de rekensom verandert. Een mens probeert een falende call drie keer en geeft op. Een agent in een lus probeert opnieuw tot zijn eigen limieten ingrijpen, en een agent met een gestolen token loopt je toollijst methodisch af op machinesnelheid.
Stel limieten in per token, per tool. Het per-token-deel zorgt dat één luidruchtige client de rest niet uithongert en geeft je per identiteit een knop om iemand af te knijpen. Het per-tool-deel weerspiegelt dat je tools verschillende prijskaartjes hebben: een goedkope leesactie kan honderden calls per minuut aan, een tool die mail verstuurt of naar productie schrijft hoort zo'n lage limiet te krijgen dat een op hol geslagen lus al bij de tweede ronde strandt.
In Laravel is dit de throttle-middleware met het token als sleutel, direct achter auth:api. In Express is het een van de standaard limiter-pakketten met req.auth.clientId als sleutel in plaats van het IP-adres, want agentverkeer achter een NAT maakt IP-limieten lek en oneerlijk tegelijk. Geef 429 terug met een Retry-After-header. Goed gebouwde agents respecteren die, en de agents die dat niet doen vertellen je welke tokens je moet intrekken.
Rate limits zijn ook je goedkoopste incident response. Token gecompromitteerd om drie uur 's nachts? De schade blijft binnen wat het plafond toelaat tot je het token intrekt, in plaats van alles wat de tools op volle snelheid kunnen.
De checklist
Het bouwartikel vroeg of je server moet bestaan. Dit artikel gaat uit van wel, en vraagt of hij klaar is voor het open internet:
- Nog op één machine? Blijf op stdio, geef credentials mee via environment variables, sla OAuth helemaal over.
- Ga je remote? Wees alleen een resource server. Identiteit hoort bij een authorization server die jij niet hebt geschreven.
- Serveer de metadata. Het RFC 9728-document op de well-known URI, plus 401 met
WWW-Authenticatedie ernaar wijst. - Controleer de audience. Weiger tokens die voor iets anders zijn uitgegeven dan jouw server, en stuur een binnenkomend token nooit door richting upstream.
- Eén scope per werkwoord. Lezen is geen schrijven, en verwijderen al helemaal niet. Beantwoord te krap gescopede calls met 403
insufficient_scope. - Rate limit per token, per tool. Stem de limiet af op de schade die een tool kan aanrichten, en leg de servers die je zelf gebruikt langs dezelfde meetlat die je nu voor je eigen server hanteert.
Niets hiervan is exotisch. Het is een JSON-document, een header, een tokencheck met één extra claim, en middleware die je framework al meelevert. Eén weekend werk, grotendeels configuratie, is het verschil tussen jouw server en de 12.500 die het oversloegen.
De tutorial gaf je een server die werkt. Dit is het deel dat zorgt dat hij ook echt alleen van jou blijft.