# Authenticatie voor MCP-servers [[TOC]] ## Authenticatie in MCP: het overzicht Het MCP-protocol zelf regelt geen inlogmechanisme op berichtniveau. Autorisatie hoort bij de transport-laag. Voor lokale stdio-servers is dat impliciet: de gebruiker die de server start, heeft de rechten en de credentials komen uit de omgeving. Zodra je server via het netwerk bereikbaar is, moet je autorisatie zelf inrichten. De MCP-autorisatiespec (versie 2025-11-25, de actuele versie in juni 2026) bouwt op OAuth 2.1. Het belangrijkste mentale model is dit: jouw MCP-server gedraagt zich als een OAuth 2.1 **resource server**. Hij geeft zelf geen tokens uit, maar valideert tokens die door een aparte **authorization server** zijn uitgegeven (bijvoorbeeld Google, Microsoft Entra, Auth0 of je eigen identity provider). De client haalt het token op bij die authorization server en stuurt het mee in elke aanroep. Welke aanpak past bij jouw situatie: | Strategie | Wanneer | Kenmerk | | --- | --- | --- | | Statische API-key | Server-to-server, geen gebruikersidentiteit | Simpel, maar geen delegatie en geen audience-binding | | OAuth 2.1 (resource server) | Servers die namens individuele gebruikers handelen | Aanbevolen voor remote productie-servers | | mTLS | Interne netwerken, beide kanten presenteren certificaten | Hoog vertrouwen, complexere setup | :::info title="SSE is vervangen door Streamable HTTP" De oude HTTP plus SSE-transport (`SSEServerTransport`) is sinds spec-versie 2025-03-26 gedeprecateerd en vervangen door **Streamable HTTP** (`StreamableHTTPServerTransport`). Nieuwe servers gebruiken Streamable HTTP. Hou je nog oude clients in de lucht, dan kun je tijdelijk beide endpoints naast elkaar serveren, maar bouw nieuw werk niet meer op SSE. ::: ## Statische API-key (alleen voor server-to-server) Voor interne koppelingen zonder gebruikersidentiteit volstaat een vaste sleutel in de `Authorization`-header. Gebruik dit niet voor servers die namens gebruikers handelen, want er is geen audience-binding en geen intrekbaarheid per gebruiker. ```typescript import express from "express"; import { randomUUID } from "node:crypto"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; const app = express(); app.use(express.json()); const VALID_KEYS = new Set(process.env.API_KEYS?.split(",") ?? []); function authMiddleware( req: express.Request, res: express.Response, next: express.NextFunction ) { const authHeader = req.headers.authorization; if (!authHeader?.startsWith("Bearer ")) { res.status(401).json({ error: "Geen Bearer-token opgegeven" }); return; } const token = authHeader.slice(7); if (!VALID_KEYS.has(token)) { res.status(403).json({ error: "Ongeldig token" }); return; } next(); } const server = new McpServer({ name: "secure-server", version: "1.0.0" }); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), }); await server.connect(transport); app.post("/mcp", authMiddleware, async (req, res) => { await transport.handleRequest(req, res, req.body); }); app.listen(3000); ``` :::warning title="Sleutels nooit in code of repo" Sla API-keys nooit op in source code, in een `.env` die je commit, of in een configuratiebestand in je repository. Gebruik omgevingsvariabelen die je buiten de codebase injecteert, of een secrets manager zoals Google Secret Manager of HashiCorp Vault. Gebruik vergelijk met constante looptijd waar mogelijk, zodat je geen timing-informatie lekt. ::: ## OAuth 2.1: jouw server als resource server Dit is de aanbevolen aanpak voor remote servers die echte gebruikers bedienen. De server geeft zelf geen inlogscherm, maar valideert het binnenkomende token en wijst de client naar de juiste authorization server. De spec vraagt een paar concrete dingen. ### 1. Antwoord op een ontbrekend token met een 401 plus WWW-Authenticate Wanneer er geen geldig token is, geef je een `401` terug met een `WWW-Authenticate`-header die naar je Protected Resource Metadata wijst (RFC 9728). Zo weet de client zelf bij welke authorization server hij een token moet halen. ```typescript function requireToken( req: express.Request, res: express.Response, next: express.NextFunction ) { const authHeader = req.headers.authorization; if (!authHeader?.startsWith("Bearer ")) { res .status(401) .set( "WWW-Authenticate", 'Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"' ) .json({ error: "Authenticatie vereist" }); return; } next(); } ``` ### 2. Publiceer Protected Resource Metadata Serveer een well-known document dat naar je authorization server(s) verwijst. De client gebruikt dit om het OAuth-verkeer te starten. ```typescript app.get("/.well-known/oauth-protected-resource", (req, res) => { res.json({ resource: "https://mcp.example.com", authorization_servers: ["https://auth.example.com"], scopes_supported: ["files:read", "files:write"], }); }); ``` ### 3. Valideer het token op audience De spec is hier streng: je server mag een token alleen accepteren als het specifiek voor jouw server is uitgegeven. Dit voorkomt dat een token dat voor een andere dienst was bedoeld, hier wordt hergebruikt. Controleer dus de handtekening, de vervaldatum en de `aud`-claim (RFC 8707). ```typescript import { createRemoteJWKSet, jwtVerify } from "jose"; const JWKS = createRemoteJWKSet( new URL("https://auth.example.com/.well-known/jwks.json") ); async function validateToken(token: string) { const { payload } = await jwtVerify(token, JWKS, { issuer: "https://auth.example.com", audience: "https://mcp.example.com", }); return payload; } ``` :::danger title="Geef tokens nooit ongewijzigd door" Token-passthrough is in de spec expliciet verboden. Als je MCP-server zelf een upstream-API aanroept (bijvoorbeeld Google Drive), dan handelt hij dáár als een aparte OAuth-client met een eigen, apart uitgegeven token. Stuur het token dat je van de MCP-client kreeg nooit door naar een upstream-dienst. Dat opent het confused deputy-lek, waarbij de upstream-API ten onrechte aanneemt dat het verzoek namens de juiste partij komt. ::: ## Token gebruiken in een tool-handler Zodra het token gevalideerd is, kun je in je tool de bijbehorende gebruiker en scopes gebruiken. Hieronder vraagt de tool bestanden op namens de ingelogde gebruiker. Het upstream-token voor Google staat los van het MCP-token en haal je uit een veilige store per gebruiker. ```typescript server.registerTool( "list_drive_files", { title: "Drive-bestanden", description: "Lijst bestanden in Google Drive van de ingelogde gebruiker", inputSchema: { folder_id: z.string().optional() }, }, async ({ folder_id }, extra) => { const userId = extra?.authInfo?.subject; if (!userId) { throw new McpError(ErrorCode.InvalidRequest, "Niet geautoriseerd."); } const upstream = await tokenStore.get(userId); if (!upstream) { throw new McpError( ErrorCode.InvalidRequest, "Geen Google-koppeling. Verbind eerst je account." ); } oauth2Client.setCredentials(upstream); const drive = google.drive({ version: "v3", auth: oauth2Client }); const response = await drive.files.list({ q: folder_id ? `'${folder_id}' in parents` : undefined, }); return { content: [{ type: "text", text: JSON.stringify(response.data.files) }], }; } ); ``` ## Rate limiting toevoegen Beperk het aantal verzoeken om misbruik en kostenexplosies te voorkomen. Sleutel bij voorkeur op de gevalideerde gebruiker, niet op het rauwe token, zodat je geen tokens in je sleutels logt. ```typescript import rateLimit from "express-rate-limit"; const limiter = rateLimit({ windowMs: 60 * 1000, max: 100, message: { error: "Te veel verzoeken, wacht een minuut" }, keyGenerator: (req) => req.headers.authorization ?? req.ip, }); app.use("/mcp", limiter); ``` ## Audit logging Log elke tool-aanroep voor compliance en incident-onderzoek. Log nooit het rauwe token of gevoelige argumenten; bewaar wel wie wat wanneer aanriep. ```typescript server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { await auditLog.write({ timestamp: new Date().toISOString(), tool: request.params.name, user: extra?.authInfo?.subject, }); }); ``` ## Checklist voor productie :::tip title="Voor je live gaat" Loop deze punten af voor elke remote MCP-server: - Server draait uitsluitend over **HTTPS** (Bearer-tokens over HTTP zijn triviaal te onderscheppen). - Tokens worden gevalideerd op handtekening, vervaltijd en **audience** (alleen tokens voor jouw server). - Een ontbrekend of ongeldig token geeft een **401 met WWW-Authenticate**, een te beperkt scope geeft een **403** met `insufficient_scope`. - De client wordt verplicht **PKCE** (S256) te gebruiken; korte access tokens plus roterende refresh tokens. - Geen **token-passthrough** naar upstream-API's; upstream-tokens staan los en per gebruiker in een veilige store. - Tokens staan in een externe store (Redis, Firestore), niet in geheugen van een stateless instance. ::: :::faq ### Moet elke MCP-server authenticatie hebben? Een lokale stdio-server die alleen op je eigen machine draait, heeft geen extra autorisatielaag nodig: de credentials komen uit de omgeving. Elke server die via het netwerk bereikbaar is, moet wel autorisatie afdwingen. ### Wat betekent het dat mijn server een resource server is? Je server geeft zelf geen tokens uit. Hij ontvangt een Bearer-token, controleert of het geldig en specifiek voor hem bedoeld is, en bedient daarna het verzoek. De tokens komen van een aparte authorization server, die ook met de gebruiker mag praten. ### Waarom moet ik de audience van een token controleren? Zonder die controle zou je server een token accepteren dat eigenlijk voor een andere dienst was uitgegeven. Een aanvaller kan dan een legitiem token van elders hergebruiken bij jouw server. De spec verplicht daarom dat je alleen tokens accepteert waarin jouw server als doelgroep staat. ### Hoe beveilig ik tokens bij een stateless deployment? Bij serverless of containers zonder vaste staat sla je tokens op in een externe store zoals Redis of Firestore, nooit in het geheugen van een losse instance. Versleutel ze in rust en geef ze een korte levensduur. ### Wat als een access token verloopt tijdens een sessie? Vang de 401 op, gebruik de refresh token om een nieuw access token te halen en herhaal de aanroep. Roteer refresh tokens voor publieke clients, zoals de spec vraagt, zodat een gelekt token beperkt bruikbaar is. ### Mag ik het MCP-token doorsturen naar Google of een andere upstream-API? Nee. Token-passthrough is verboden. Als je server upstream-diensten aanroept, doet hij dat als een aparte OAuth-client met een eigen token dat door die upstream-authorization server is uitgegeven. Het token van de MCP-client blijft bij jouw server. :::