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) dansaccess.logen 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.
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 parsecrets.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,
HTTPSsystématique en production. - Limitation de débit par appelant renvoyant
429avecRetry-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.