
Un serveur MCP en transport stdio est protégé par construction : seul le processus parent peut lui parler. Dès que vous l'exposez en HTTP, il devient joignable par n'importe qui sur le réseau — et il exécute des actions. Il lui faut une porte. Ce guide protège un serveur MCP avec OAuth 2.1, selon la spécification d'autorisation MCP : authentik joue le serveur d'autorisation, le serveur MCP devient un Resource Server qui vérifie les jetons sans jamais en émettre. Vous déploierez authentik, configurerez un provider OAuth2, écrirez un serveur MCP qui valide les JWT via la JWKS de l'émetteur, puis vérifierez les quatre comportements attendus — refus sans jeton, refus si invalide, refus si la portée manque, accès si tout est correct. Public visé : développeur ayant déjà écrit un serveur MCP HTTP.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Comprendre pourquoi un serveur MCP HTTP exige une authentification.
- Distinguer les trois rôles OAuth 2.1 : serveur d'autorisation, Resource Server, client.
- Déployer authentik comme serveur d'autorisation et y configurer un provider OAuth2.
- Protéger un serveur MCP avec un
TokenVerifierqui valide les JWT via JWKS. - Obtenir un jeton et distinguer les grants
client_credentialsetauthorization_code.
Prérequis
Section intitulée « Prérequis »Ce guide prolonge la série MCP. Il suppose acquis :
- Un serveur MCP fonctionnel et le transport HTTP — voir Créer son premier serveur MCP et Transports MCP.
- Docker et Docker Compose, pour faire tourner authentik.
- Des notions d'OAuth 2.0 / OpenID Connect — sinon, lisez OpenID Connect.
- Python 3.10+.
Pourquoi un serveur MCP HTTP a besoin d'OAuth
Section intitulée « Pourquoi un serveur MCP HTTP a besoin d'OAuth »Le risque tient à une bascule de modèle de menace. En stdio, le serveur MCP est un sous-processus : son seul interlocuteur est l'hôte qui l'a lancé. En HTTP — transport Streamable HTTP — le serveur écoute sur un port, et tout client qui connaît l'URL peut appeler ses tools. Or un tool n'est pas une lecture inoffensive : il agit. Exposer un serveur MCP HTTP sans authentification, c'est publier une API d'actions en accès libre.
La spécification d'autorisation MCP répond à ce besoin en réutilisant OAuth 2.1 — pas un mécanisme maison. Elle pose une règle claire : le serveur MCP ne gère ni mots de passe, ni comptes. Il délègue l'authentification à un serveur d'autorisation dédié et se contente de vérifier les jetons que celui-ci émet. Cette séparation est le cœur du sujet : un serveur MCP bien conçu en sécurité est un serveur MCP qui ne fait pas d'authentification lui-même.
Les trois rôles : qui authentifie, qui vérifie, qui appelle
Section intitulée « Les trois rôles : qui authentifie, qui vérifie, qui appelle »OAuth 2.1 répartit la responsabilité entre trois acteurs. Les confondre est la première source d'erreur ; les séparer rend l'architecture limpide.
| Rôle | Tenu par | Responsabilité |
|---|---|---|
| Serveur d'autorisation | authentik | Authentifie l'utilisateur ou la machine, émet les access tokens |
| Resource Server | Le serveur MCP | Vérifie les jetons, expose les tools aux seuls appelants valides |
| Client | Claude Desktop, un script, un agent | Obtient un jeton, l'envoie à chaque requête |
Le serveur MCP n'est jamais le serveur d'autorisation. Il ne stocke aucun secret d'utilisateur, ne gère aucune session de connexion. Il reçoit un jeton dans l'en-tête Authorization: Bearer, en vérifie la validité cryptographique et la portée, puis laisse passer — ou refuse. Tout le travail d'identité vit dans authentik.
Cette page met les trois rôles en place sur une seule machine : authentik en conteneurs, le serveur MCP en Python local, un client en script. Le code complet est disponible dans le lab lab-ia-mcp/mcp/oauth.
Déployer authentik comme serveur d'autorisation
Section intitulée « Déployer authentik comme serveur d'autorisation »authentik est un fournisseur d'identité open source. Le blog détaille son déploiement dans Installer authentik avec Docker Compose — trois conteneurs : PostgreSQL, le serveur, le worker.
Pour un lab reproductible, deux variables d'environnement changent la donne. AUTHENTIK_BOOTSTRAP_PASSWORD et AUTHENTIK_BOOTSTRAP_TOKEN créent au premier démarrage le compte akadmin et un jeton d'API connu — ce qui rend la configuration scriptable, sans passer par l'interface web.
server: # Image épinglée par tag ET digest : reproductibilité et supply chain. image: ghcr.io/goauthentik/server:2026.2.1@sha256:46a71d75dfd3eec9bd0fb42e5e13a245394e1be2d0828eebfbb2662421e66a35 command: server environment: AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?} AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:?} AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN:?} ports: - "9000:9000"Démarrez la stack, puis attendez que la sonde de disponibilité réponde :
docker compose up -dcurl -s -o /dev/null -w '%{http_code}\n' http://localhost:9000/-/health/ready/Un code 200 confirme qu'authentik a terminé ses migrations et qu'il est prêt à recevoir des appels d'API.
Configurer le provider OAuth2
Section intitulée « Configurer le provider OAuth2 »Un provider OAuth2 dans authentik, c'est la définition d'une application cliente : son client_id, son client_secret, ses scopes, sa clé de signature. Le script configure_authentik.py du lab le crée via l'API, de façon idempotente — on peut le rejouer sans rien casser.
Trois objets sont créés. Un scope mapping mcp:read : la portée applicative que le serveur MCP exigera. Le provider lui-même, en client confidentiel, signé en RS256 pour que les access tokens soient des JWT vérifiables. Une application qui rend le provider accessible.
provider = api.post("/providers/oauth2/", json={ "name": "mcp-blog-helper", "authorization_flow": AUTH_FLOW, "client_type": "confidential", "signing_key": SIGNING_KEY, # certificat RS256 "access_token_validity": "minutes=10", "property_mappings": mappings, # mcp:read + scopes OIDC}).raise_for_status().json()Le script crée enfin un compte de service et son jeton. Ce compte permettra au script de test d'obtenir des access tokens en mode machine-à-machine, sans navigateur. À l'issue, les identifiants — client_id, client_secret, URL du token endpoint et de la JWKS — sont écrits dans un fichier credentials.env que le serveur et le client liront.
Protéger le serveur MCP
Section intitulée « Protéger le serveur MCP »Côté serveur MCP, l'authentification repose sur deux éléments du SDK : un TokenVerifier — l'objet qui sait dire si un jeton est valide — et un bloc AuthSettings — la déclaration de ce que le serveur exige.
Le TokenVerifier est un protocole avec une seule méthode, verify_token. Notre implémentation valide un JWT émis par authentik : récupération de la clé publique via la JWKS, puis contrôle de la signature, de l'émetteur (iss), de l'audience (aud) et de l'expiration (exp). Tout échec renvoie None, et le serveur répondra 401.
import jwtfrom mcp.server.auth.provider import AccessToken, TokenVerifier
class AuthentikTokenVerifier(TokenVerifier): def __init__(self) -> None: self._jwks = jwt.PyJWKClient(CONF["OAUTH_JWKS_URL"])
async def verify_token(self, token: str) -> AccessToken | None: try: key = self._jwks.get_signing_key_from_jwt(token) claims = jwt.decode( token, key, algorithms=["RS256"], audience=CLIENT_ID, issuer=ISSUER, ) except jwt.InvalidTokenError: return None return AccessToken( token=token, client_id=claims.get("azp", CLIENT_ID), scopes=claims.get("scope", "").split(), expires_at=claims.get("exp"), )Le serveur déclare ensuite ce verifier et sa politique d'accès. required_scopes=["mcp:read"] est décisif : il ne suffit pas qu'un jeton soit authentique, il doit aussi porter la bonne portée.
from mcp.server.auth.settings import AuthSettingsfrom mcp.server.fastmcp import FastMCP
mcp = FastMCP( "blog-helper-secure", host="127.0.0.1", port=8800, token_verifier=AuthentikTokenVerifier(), auth=AuthSettings( issuer_url=ISSUER, resource_server_url="http://localhost:8800", required_scopes=["mcp:read"], ),)Avec cette configuration, le serveur lancé en transport="streamable-http" applique l'authentification à chaque requête. Une requête sans jeton ne reçoit pas un mur muet, mais une réponse conforme à OAuth 2.1 :
curl -s -D - -o /dev/null -X POST http://localhost:8800/mcp -d '{}'HTTP/1.1 401 Unauthorizedwww-authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://localhost:8800/.well-known/oauth-protected-resource"L'en-tête WWW-Authenticate pointe vers un document de métadonnées que le serveur publie automatiquement. Ce document indique au client quel est le serveur d'autorisation à contacter et quelles portées sont attendues — c'est le mécanisme par lequel un client comme Claude Desktop découvre comment s'authentifier.
Obtenir un jeton et appeler le serveur
Section intitulée « Obtenir un jeton et appeler le serveur »Reste le troisième rôle : le client. Pour obtenir un access token, OAuth 2.1 offre plusieurs grants. Deux comptent ici, et il faut les distinguer.
Le grant authorization_code avec PKCE est le flux interactif. C'est celui qu'utilise Claude Desktop : à la première connexion à un serveur MCP protégé, l'application ouvre le navigateur, l'utilisateur s'authentifie sur authentik, et le client récupère un jeton. C'est le flux recommandé pour tout client piloté par un humain.
Le grant client_credentials est le flux machine-à-machine : pas de navigateur, pas d'utilisateur. Un script, un job de CI ou un agent non interactif présente un client_id, un client_secret et les identifiants d'un compte de service, et reçoit un jeton. C'est ce flux que le lab utilise pour ses tests, parce qu'il est entièrement scriptable.
response = httpx.post(conf["OAUTH_TOKEN_URL"], data={ "grant_type": "client_credentials", "client_id": conf["OAUTH_CLIENT_ID"], "client_secret": conf["OAUTH_CLIENT_SECRET"], "username": conf["SA_USERNAME"], "password": conf["SA_TOKEN"], "scope": "openid mcp:read",})token = response.json()["access_token"]Le jeton obtenu accompagne ensuite chaque requête MCP. Avec le client Streamable HTTP du SDK, on passe un httpx.AsyncClient pré-configuré avec l'en-tête d'autorisation :
auth = {"Authorization": f"Bearer {token}"}async with httpx.AsyncClient(headers=auth) as http: async with streamable_http_client(url, http_client=http) as (read, write, _): async with ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools()Valider : la suite de tests
Section intitulée « Valider : la suite de tests »La sécurité ne se constate pas, elle se teste. Le lab automatise les quatre comportements qui définissent un Resource Server correct, avec pytest et le grant client_credentials.
test_oauth.py::test_sans_jeton_renvoie_401 PASSEDtest_oauth.py::test_jeton_invalide_renvoie_401 PASSEDtest_oauth.py::test_jeton_sans_le_scope_renvoie_403 PASSEDtest_oauth.py::test_jeton_valide_liste_les_tools PASSEDtest_oauth.py::test_jeton_valide_appelle_un_tool PASSEDChaque test verrouille une garantie. Sans jeton : 401. Jeton invalide — une chaîne qui n'est pas un JWT signé : 401. Jeton authentique mais sans le scope mcp:read : 403 — la distinction est essentielle, car elle prouve que le serveur vérifie la portée, pas seulement l'identité. Jeton valide et complet : l'accès aux tools fonctionne. Ce dernier cas traverse toute la chaîne — authentik émet, le serveur vérifie via la JWKS, MCP répond.
La méthode rejoint celle du guide Tester et déboguer un serveur MCP : on ne se fie pas à un essai manuel, on fige le comportement attendu dans une suite rejouable à chaque modification.
Dépannage
Section intitulée « Dépannage »| Symptôme | Cause probable | Solution |
|---|---|---|
401 avec un jeton pourtant obtenu | aud ou iss du JWT ne correspond pas | Vérifier audience= et issuer= dans jwt.decode |
403 sur un jeton valide | Le scope mcp:read n'est pas dans le jeton | Demander scope=mcp:read et l'inclure dans les property mappings du provider |
InvalidSignatureError | Mauvaise JWKS ou clé de signature changée | Vérifier OAUTH_JWKS_URL ; la clé de signature du provider doit être un certificat RS256 |
| Le client se bloque sans réponse | Requête sur /mcp/ (avec slash final) | Appeler /mcp sans slash final — sinon redirection 307 |
ExpiredSignatureError | Access token expiré (10 min) | Réobtenir un jeton ; en interactif, laisser le refresh token opérer |
authentik renvoie invalid_client | client_id ou client_secret erroné | Régénérer credentials.env avec configure_authentik.py |
À retenir
Section intitulée « À retenir »- Un serveur MCP stdio est isolé ; un serveur MCP HTTP exige une authentification — il expose des actions.
- La spécification d'autorisation MCP réutilise OAuth 2.1 : le serveur MCP est un Resource Server, jamais un serveur d'autorisation.
- Trois rôles séparés : authentik authentifie et émet, le serveur MCP vérifie, le client présente le jeton.
- Le serveur valide un JWT via la JWKS de l'émetteur : signature,
iss,aud,exp— aucun appel à authentik par requête. required_scopesimpose une portée : un jeton authentique sans le bon scope reçoit403, pas200.client_credentialsest le grant machine (scriptable, testable) ;authorization_code+ PKCE est le grant interactif de Claude Desktop.- Une requête non authentifiée reçoit
401+ un en-têteWWW-Authenticatequi permet au client de découvrir le serveur d'autorisation.
Prochaines étapes
Section intitulée « Prochaines étapes »Pour aller plus loin
Section intitulée « Pour aller plus loin »- Spécification d'autorisation MCP — la référence officielle du modèle OAuth 2.1 pour MCP.
- Documentation OAuth2 d'authentik — providers, scopes et grants côté serveur d'autorisation.
- PyJWT — la bibliothèque de vérification des JWT utilisée par le serveur.