Aller au contenu principal

Module 7 — Authentification par jeton

Un service de scoring public sans authentification expose deux risques : une facture cloud qui explose parce qu'un attaquant l'appelle un million de fois, et une fuite de logique métier — les seuils, l'ordre des variables, la structure des prédictions — vers un tiers non autorisé. Ce module pose la première ligne de défense, la clé d'API en en-tête, et son évolution naturelle vers un jeton signé.

Le contrat minimal : une clé, un en-tête

La forme la plus simple et la plus déployée est la clé d'API. Chaque appelant reçoit une chaîne aléatoire longue, l'envoie dans un en-tête X-API-Key, et le service vérifie qu'elle figure dans la liste des clés valides.

from fastapi import FastAPI, Depends, HTTPException, Header, status

CLES_VALIDES = {
"streamlit-tableau-bord": "clé-secrète-1-très-longue-et-aléatoire",
"batch-nocturne": "clé-secrète-2-très-longue-et-aléatoire",
}

def verifier_cle(x_api_key: str = Header(..., alias="X-API-Key")) -> str:
for appelant, cle in CLES_VALIDES.items():
if secrets.compare_digest(cle, x_api_key):
return appelant
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="clé d'API absente ou invalide",
headers={"WWW-Authenticate": "ApiKey"},
)

app = FastAPI()

@app.post("/predict")
def predict(
dossier: DossierAbonne,
appelant: str = Depends(verifier_cle),
):
# appelant = "streamlit-tableau-bord" ou "batch-nocturne"
return {"appelant": appelant, "probabilite": 0.42}

Trois choses sont non négociables. La comparaison passe par secrets.compare_digest, pas par ==, pour ne pas fuir la clé via un oracle temporel. La clé arrive dans un en-tête, jamais dans l'URL — l'URL est journalisée par le serveur, par le proxy, par le navigateur, par le client HTTP. Enfin, la dépendance renvoie l'identifiant de l'appelant qui a présenté la clé : on le journalisera (module 8) sur chaque prédiction pour tracer qui a demandé quoi.

Pourquoi jamais dans l'URL

C'est la faute la plus fréquente en 2026 encore. Un tutoriel ancien propose ?api_key=xxx en query string. Toute clé qui passe par ce canal est :

  • journalisée par le proxy inverse (nginx, Apache, Traefik) dans access.log en clair ;
  • copiée par l'appelant sur un chat, par la fonctionnalité de partage du navigateur, par un ticket d'assistance ;
  • indexée par des moteurs qui scrutent les fichiers de journal exposés.

Une clé dans l'URL doit être considérée comme déjà fuite et remplacée dès qu'on la découvre.

Rotation des clés

Une clé n'est pas un mot de passe : c'est une valeur qu'on remplace régulièrement, et qu'on peut révoquer à tout moment. Deux propriétés à tenir.

Une clé porte un identifiant d'appelant. On stocke non pas {cle: valide} mais {cle: (appelant, expiration)} : révoquer un appelant particulier revient à supprimer sa ligne, sans casser les autres.

La rotation se fait avec chevauchement. Pendant la fenêtre de rotation (une semaine, par exemple), les deux clés — l'ancienne et la nouvelle — sont valides. L'appelant migre puis on désactive l'ancienne. Sans chevauchement, la rotation devient une interruption de service.

Dans une vraie plateforme, la liste des clés ne vit pas dans le code mais dans un magasin qu'on peut modifier sans redéployer : Redis, une base Postgres, AWS Secrets Manager, HashiCorp Vault. Une lecture au démarrage suffit tant que le service redémarre proprement à chaque rotation ; sinon, une lecture toutes les 60 secondes avec cache limite l'impact d'une révocation.

JWT : le jeton signé, en aperçu

La clé d'API est opaque : le service doit la comparer à une liste. Le JSON Web Token (JWT) est auto-descriptif : c'est un JSON signé cryptographiquement qui contient l'identité de l'appelant, ses droits, son expiration. Le service vérifie la signature avec une clé publique et lit les droits directement.

from jose import jwt, JWTError

CLE_PUBLIQUE = open("cle-publique.pem").read()

def verifier_jeton(authorization: str = Header(..., alias="Authorization")) -> dict:
if not authorization.startswith("Bearer "):
raise HTTPException(401, "en-tête Authorization invalide")
jeton = authorization.removeprefix("Bearer ")
try:
return jwt.decode(jeton, CLE_PUBLIQUE, algorithms=["RS256"])
except JWTError as e:
raise HTTPException(401, f"jeton invalide : {e}")

JWT convient quand un service central d'authentification émet des jetons consommés par plusieurs services : chacun vérifie sans appeler le central, ce qui divise la charge. Il ne convient pas à un service isolé — la clé d'API reste plus simple, plus révocable, plus opérable.

Limitation de débit

L'authentification identifie ; la limitation de débit protège. Un appelant authentifié qui envoie 10 000 requêtes par seconde doit être ralenti, sinon il fait tomber le service pour tout le monde. On limite par appelant, pas par IP — deux appelants peuvent partager un proxy.

slowapi est la bibliothèque de référence pour FastAPI, adossée à Redis pour un compteur partagé entre travailleurs.

from slowapi import Limiter
from slowapi.util import get_remote_address

limiter = Limiter(key_func=lambda request: request.state.appelant)
app.state.limiter = limiter

@app.post("/predict")
@limiter.limit("100/minute")
def predict(request: Request, dossier: DossierAbonne, appelant=Depends(verifier_cle)):
request.state.appelant = appelant
# ...

Le service renvoie alors 429 Too Many Requests avec un en-tête Retry-After: 15 (secondes) au 101ᵉ appel de la minute. L'appelant, en lisant Retry-After, sait quand rejouer.

HTTPS n'est pas optionnel

Toute clé, tout jeton, tout mot de passe voyage en clair sur HTTP. La règle est simple : aucun service authentifié ne s'expose sans TLS. Sur un service géré (Cloud Run, ECS Fargate, App Runner), TLS est fourni sans configuration. Derrière un proxy inverse (Traefik, nginx), on active Let's Encrypt en trois lignes. Sur un serveur brut, on met un proxy devant, jamais Uvicorn nu sur le port 443.

Journaliser sans fuir la clé

La règle d'or : la clé ne sort jamais des logs. On la remplace par l'identifiant d'appelant qu'elle a résolu.

logger.info("scoring appelant=%s abonne=%s", appelant, dossier.abonne_id)
# JAMAIS : logger.info("scoring cle=%s", x_api_key)

Un log qui contient une clé pollue tous les systèmes qui l'agrègent (Elasticsearch, Datadog, Sentry) et oblige à une rotation urgente si quelqu'un y accède.

La règle « pas de secret dans le code »

Ne jamais committer une clé, même de test, dans le dépôt Git. On utilise .env (ignoré par Git), une variable d'environnement injectée par l'orchestrateur (Kubernetes Secret, Docker Compose environment:) ou un magasin de secrets. Une clé committée par erreur reste dans l'historique Git : il faut la réputée compromise et la faire tourner immédiatement, même après un git rebase -i.

En résumé

  • Clé d'API dans un en-tête X-API-Key, jamais dans l'URL ; comparaison par secrets.compare_digest.
  • Chaque clé porte l'identifiant d'un appelant, ce qui rend la révocation et la journalisation par appelant possibles.
  • Rotation avec chevauchement, secrets hors du dépôt Git, HTTPS systématique en production.
  • Limitation de débit par appelant renvoyant 429 avec Retry-After ; JWT pour un écosystème multi-service, clé d'API sinon.

Le module 8 rend le service observable : journaux structurés avec identifiant de requête, sondes de santé et métriques Prometheus.