Architecture microservices en pratique (2/2) : Données, config et auth (JWT + Forward Auth)
Deuxième article : comment chaque service possède ses propres données, comment la configuration et les secrets sont lus au démarrage…
Architecture microservices en pratique (2/2) : Données, config et auth (JWT + Forward Auth)
Deuxième article : comment chaque service possède ses propres données, comment la configuration et les secrets sont lus au démarrage (Consul + Vault), et comment l’authentification est centralisée grâce au JWT et au Forward Auth.
Introduction : données et identité au cœur de l’architecture
Une fois la gateway et le routage en place (article 1), deux enjeux majeurs restent : où et comment chaque microservice stocke ses données, et comment il sait qui appelle son API sans avoir à gérer lui-même les jetons et les sessions. Dans une architecture microservices, on évite qu’un service accède directement aux tables d’un autre : chaque service est propriétaire de ses données et les expose uniquement via son API. Par ailleurs, on ne veut pas que chaque backend valide le JWT, appelle Keycloak ou gère des sessions : on centralise tout ça à la gateway (Forward Auth), et les microservices ne reçoivent que des headers contenant l’identité de l’utilisateur. Cet article détaille ces deux piliers — données par service, config/secrets via Consul et Vault, puis auth centralisée — avec des extraits de code et de configuration tirés du projet Djassa.

1. Une base par service : principe et intérêt
En monolithe, une seule base de données contient les tables de tous les modules. En microservices, on inverse la logique : chaque service possède sa propre base (ou ses propres magasins de données). Le service « commandes » ne fait pas de JOIN sur la table users ou products d'une autre base : il stocke les identifiants (user_id, product_id) et, si besoin, des données dénormalisées (nom du produit, prix au moment de la commande) pour éviter d'appeler le service products à chaque affichage. La cohérence à long terme est assurée par les APIs, les événements ou les sagas, pas par une transaction SQL globale.
Les avantages sont opérationnels et techniques : on peut choisir le bon outil par domaine (relationnel pour les commandes, moteur de recherche pour le catalogue, stockage objet pour les images), scaler indépendamment (par exemple plus de réplicas lecture pour le catalogue), déployer des changements de schéma sans impacter les autres services, et limiter la casse en cas de panne ou de migration (un seul service est concerné).
2. Mise en œuvre : une base par service dans Docker
On peut démarrer avec une seule instance PostgreSQL et plusieurs bases (une par microservice) pour simplifier l’exploitation en dev ou en petite prod. Un script d’init crée les bases au premier démarrage du conteneur Postgres :
#!/bin/bash
# postgres-init/01-create-databases.sh
# Création des bases par service (une base par microservice)
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL
CREATE DATABASE users_db;
CREATE DATABASE products_db;
CREATE DATABASE orders_db;
CREATE DATABASE payments_db;
CREATE DATABASE inventory_db;
EOSQL
Dès que la charge ou l’isolation le justifient, on passe à une instance PostgreSQL par service. Chaque service a alors son propre conteneur Postgres, son volume et ses variables d’environnement. Exemple dans un docker-compose.yml :
postgres-users:
image: postgres:15
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: users_db
ports:
- "5432:5432"
volumes:
- pgdata_users:/var/lib/postgresql/data
networks:
- backend
postgres-products:
image: postgres:15
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: products_db
volumes:
- pgdata_products:/var/lib/postgresql/data
networks:
- backend
# Idem pour postgres-orders, postgres-payments, postgres-inventory
Le service users ne connaît que users_db ; le service products que products_db. Les chaînes de connexion (host, port, base, utilisateur, mot de passe) ne sont pas en dur : elles sont construites à partir de la config (Consul) et des secrets (Vault), comme nous le verrons plus bas.

les containers des bases postgres de chaque services
3. Choix du stockage selon l’usage
Tous les services n’ont pas les mêmes besoins. Un tableau récapitulatif aide à affecter le bon type de stockage :

Pour products, la source de vérité reste PostgreSQL (CRUD, contraintes, relations). Elasticsearch est alimenté à chaque création/mise à jour/suppression de produit pour offrir la recherche full-text ; MinIO stocke les fichiers (images) et le service enregistre l’URL ou le chemin dans Postgres. Ainsi un même service peut combiner plusieurs magasins tout en restant maître de son domaine : les autres services n’accèdent pas directement à la base products ni à l’index Elasticsearch ; ils passent par l’API du service products.

Tableau de bord MinIO (bucket product-images)
4. Config dans Consul KV, secrets dans Vault
La configuration (noms de bases, hosts, URLs, timeouts, feature flags) et les secrets (mots de passe, clés API) ne doivent pas être mélangés dans le code ni dans un même stockage. On sépare clairement : Consul KV pour la config non sensible, Vault pour les secrets. Ainsi on peut faire tourner la même image de service en dev, staging et prod en ne changeant que les variables d’environnement CONSUL_HTTP_ADDR et VAULT_ADDR (et les tokens) : la config et les secrets sont différents par environnement mais le code reste identique.
Un script d’init (exécuté une fois au démarrage de la stack) remplit Consul. Chaque service a son propre « namespace » sous config/<nom_du_service>/. Exemple extrait de consul-config/init-kv.sh :
#!/bin/sh
export CONSUL_HTTP_ADDR=${CONSUL_HTTP_ADDR:-http://consul:8500}
echo "Waiting for Consul..."
until wget -qO- "$CONSUL_HTTP_ADDR/v1/status/leader" 2>/dev/null | grep -q .; do sleep 2; done
echo "Injecting config into Consul KV..."
# --- Users (PostgreSQL dédié postgres-users) ---
/bin/consul kv put config/users/database users_db 2>/dev/null || true
/bin/consul kv put config/users/host postgres-users 2>/dev/null || true
/bin/consul kv put config/users/redis_url "redis://redis:6379/0" 2>/dev/null || true
/bin/consul kv put config/users/cache_ttl_seconds "300" 2>/dev/null || true
/bin/consul kv put config/users/keycloak_url "http://keycloak:8080" 2>/dev/null || true
/bin/consul kv put config/users/realm djassa 2>/dev/null || true
/bin/consul kv put config/users/audience djassa-client 2>/dev/null || true
# --- Products (PostgreSQL + Elasticsearch + MinIO) ---
/bin/consul kv put config/products/database products_db 2>/dev/null || true
/bin/consul kv put config/products/host postgres-products 2>/dev/null || true
/bin/consul kv put config/products/redis_url "redis://redis:6379/0" 2>/dev/null || true
/bin/consul kv put config/products/elasticsearch_url "http://elasticsearch:9200" 2>/dev/null || true
/bin/consul kv put config/products/minio_endpoint "minio:9000" 2>/dev/null || true
/bin/consul kv put config/products/minio_bucket "product-images" 2>/dev/null || true
# --- Orders, Payments, Inventory (même logique) ---
/bin/consul kv put config/orders/database orders_db 2>/dev/null || true
/bin/consul kv put config/orders/host postgres-orders 2>/dev/null || true
# ...
Les secrets (mots de passe Postgres, clés MinIO, client_secret Keycloak) ne vont jamais dans Consul ; ils sont dans Vault (voir article 1 pour le script vault/init.sh). Chaque service, au démarrage, lit sa config sous config/<service>/... et ses secrets sous secret/data/... (postgres, minio, keycloak, etc.).

Consul UI, onglet Key/Value : arbre config/users/, config/products/ avec les clés (database, host, redis_url, elasticsearch_url, minio_endpoint, etc.)
5. Lire Consul et Vault au démarrage (Python)
Le service n’a que trois variables d’environnement indispensables : VAULT_ADDR, VAULT_TOKEN, CONSUL_HTTP_ADDR. Tout le reste (URL de la base, host Postgres, URL Redis, URL Keycloak, clé publique JWT, etc.) est chargé au démarrage en appelant l'API Consul (KV) et l'API Vault (KV v2). On centralise cette logique dans un module config qui expose un objet settings (ou une classe Settings) utilisé partout dans l'application.
Exemple de structure pour un service users (FastAPI) :
# app/core/config.py
import os
import time
import base64
import httpx
from functools import lru_cache
SERVICE_NAME = "users"
_VAULT_ADDR = os.getenv("VAULT_ADDR", "http://vault:8200")
_VAULT_TOKEN = os.getenv("VAULT_TOKEN", "root")
_CONSUL_ADDR = os.getenv("CONSUL_HTTP_ADDR", "http://consul:8500")
class VaultClient:
def __init__(self, vault_addr: str, vault_token: str):
self.vault_addr = vault_addr.rstrip("/")
self.headers = {"X-Vault-Token": vault_token}
def get_secret(self, path: str, key: str, retries: int = 12, delay: float = 5.0) -> str:
url = f"{self.vault_addr}/v1/{path}"
for attempt in range(retries):
try:
r = httpx.get(url, headers=self.headers, timeout=5.0)
r.raise_for_status()
return r.json()["data"]["data"][key]
except Exception as e:
if attempt == retries - 1:
raise RuntimeError(f"Vault {path}/{key}: {e}")
time.sleep(delay)
class ConsulConfigClient:
def __init__(self, consul_addr: str):
self.consul_addr = consul_addr.rstrip("/")
def get(self, key: str, default: str = "") -> str:
# Consul KV renvoie une liste ; la valeur est en base64
url = f"{self.consul_addr}/v1/kv/config/{SERVICE_NAME}/{key}"
try:
r = httpx.get(url, timeout=3.0)
if r.status_code != 200:
return default
data = r.json()
if not data or not data[0].get("Value"):
return default
return base64.b64decode(data[0]["Value"]).decode("utf-8")
except Exception:
return default
@lru_cache()
def get_settings():
vault = VaultClient(_VAULT_ADDR, _VAULT_TOKEN)
consul = ConsulConfigClient(_CONSUL_ADDR)
postgres_user = vault.get_secret("secret/data/postgres", "username")
postgres_password = vault.get_secret("secret/data/postgres", "password")
postgres_db = consul.get("database") or "users_db"
postgres_host = consul.get("host") or "postgres-users"
database_url = (
f"postgresql+asyncpg://{postgres_user}:{postgres_password}"
f"@{postgres_host}:5432/{postgres_db}"
)
keycloak_url = consul.get("keycloak_url") or "http://keycloak:8080"
keycloak_realm = consul.get("realm") or "djassa"
redis_url = consul.get("redis_url") or "redis://redis:6379/0"
# ... autres clés
class Settings:
DATABASE_URL = database_url
KEYCLOAK_URL = keycloak_url
KEYCLOAK_REALM = keycloak_realm
REDIS_URL = redis_url
# ...
return Settings()
settings = get_settings()
Pour le service products, on ajoute la lecture de elasticsearch_url, minio_endpoint, minio_bucket depuis Consul et de secret/data/minio (access_key, secret_key) depuis Vault. Aucun fichier .env ni mot de passe en clair dans le dépôt : tout est résolu au runtime. Le @lru_cache() garantit qu'on ne fait qu'un seul tour Consul/Vault au premier accès à get_settings().
6. Forward Auth : valider le JWT une seule fois
Si chaque microservice devait valider le JWT lui-même, il faudrait que chacun charge la clé publique Keycloak, gère le cache, gère les erreurs de signature ou d’expiration, etc. Duplication et risque d’incohérence. L’idée du Forward Auth est de déléguer cette responsabilité à un seul service (l’auth-proxy) : Traefik appelle l’auth-proxy avant d’envoyer la requête au backend ; si l’auth-proxy renvoie 200, Traefik transmet la requête au microservice en y ajoutant les headers fournis par l’auth-proxy (X-User-Id, X-User-Username, X-User-Email) ; si l’auth-proxy renvoie 401, Traefik renvoie 401 au client sans jamais appeler le backend. Les microservices n’ont plus qu’à lire les headers : ils font confiance à la gateway (réseau interne).
L’endpoint de validation dans l’auth-proxy (FastAPI) fait trois choses : (1) déterminer le path de la requête (pour savoir si c’est une route publique) ; (2) si la route est publique (login, register, health, docs), renvoyer 200 sans vérifier le token ; (3) sinon, extraire le Bearer token, le valider (signature, expiration, audience) et renvoyer soit 401, soit 200 avec les headers à transmettre au backend.
# auth-proxy/app/controllers/auth_controller.py
from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Security, Request, Response
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.services.auth_service import AuthService
from app.core.config import settings
router = APIRouter()
security = HTTPBearer(auto_error=False)
auth_service = AuthService()
def _get_request_path(request: Request) -> str:
"""Traefik Forward Auth envoie l'URL originale dans X-Forwarded-Uri."""
raw = (
request.headers.get("X-Forwarded-Uri")
or request.headers.get("X-Forwarded-URI")
or request.headers.get("X-Original-Url")
or ""
)
if raw.startswith("http://") or raw.startswith("https://"):
return urlparse(raw).path or request.url.path
return raw or request.url.path
@router.api_route("/validate", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
async def validate_token(
request: Request,
response: Response,
credentials: HTTPAuthorizationCredentials = Security(security),
):
path = _get_request_path(request)
path_norm = (path or "").rstrip("/") or "/"
# Routes publiques : pas d'auth requise (login, register, health, docs, metrics)
for public in settings.PUBLIC_PATHS_LIST:
p = (public or "").rstrip("/") or "/"
if path_norm == p or path_norm.startswith(p + "/"):
return {"detail": "Public route, no auth required"}
if not credentials:
raise HTTPException(status_code=401, detail="Authorization header missing")
token = credentials.credentials
try:
payload, user_info = await auth_service.validate_token_and_get_user(token)
# Ces headers seront recopiés par Traefik sur la requête envoyée au backend
if payload.get("sub"):
response.headers["X-User-Id"] = payload["sub"]
if payload.get("preferred_username"):
response.headers["X-User-Username"] = payload["preferred_username"]
if payload.get("email"):
response.headers["X-User-Email"] = payload["email"]
return {"payload": payload}
except Exception as e:
raise HTTPException(status_code=401, detail=str(e))
La liste PUBLIC_PATHS_LIST est centralisée (dans la config de l'auth-proxy, éventuellement chargée depuis Consul) pour rester cohérente avec les routes réellement exposées (ex. /api/users/v1/login, /api/users/v1/health, /api/users/docs, etc.). Traefik est configuré (via les tags Consul) pour appeler http://auth-proxy:8000/v1/validate et pour recopier les headers de la réponse vers la requête backend :
traefik.http.middlewares.auth.forwardauth.address=http://auth-proxy:8000/v1/validate
traefik.http.middlewares.auth.forwardauth.authResponseHeaders=X-User-Id,X-User-Username,X-User-Email
7. Utiliser l’identité dans les microservices
Une fois ces headers en place, les routes protégées des microservices n’ont plus qu’à les lire. Pas de parsing de JWT, pas d’appel à Keycloak : l’utilisateur courant est identifié par X-User-Id (le sub du JWT), et optionnellement par X-User-Username et X-User-Email. Exemple dans un endpoint GET /v1/me du service users :
# users/app/api/v1/endpoints/users.py
from fastapi import APIRouter, Request, HTTPException
@router.get("/me")
async def get_me(request: Request):
user_id = request.headers.get("x-user-id")
if not user_id:
raise HTTPException(status_code=401, detail="Unauthorized")
username = request.headers.get("x-user-username") or "user"
email = request.headers.get("x-user-email")
# Charger le profil depuis la DB users avec user_id (correspond au sub Keycloak)
# ...
return {"id": user_id, "username": username, "email": email, ...}
Dans le service orders, pour créer une commande, on récupère de la même façon user_id pour associer la commande à l'utilisateur ; dans payments, pour enregistrer un paiement, on utilise encore user_id. Le code reste simple et uniforme : un seul mécanisme d'identité, porté par les headers, sans dépendance à une librairie JWT dans chaque service.

Swagger/OpenAPI du service users (ex. /api/users/docs)
8. Routes publiques vs protégées
La frontière entre « route publique » et « route protégée » doit être explicite et cohérente. Toute route listée dans PUBLIC_PATHS_LIST (auth-proxy) est accessible sans token : health, login, register, docs, openapi.json, metrics. Toutes les autres (par exemple GET /v1/me, POST /v1/orders, etc.) sont protégées : sans token valide, l'auth-proxy renvoie 401 et Traefik ne transmet pas la requête au backend. En pratique, il est utile de documenter cette liste dans le README ou dans la doc d'architecture pour que les développeurs sachent quels chemins sont exemptés d'auth.
9. Résumé
- Une base par service : script Postgres ou une instance par service ; pas de jointures entre domaines ; possibilité de combiner plusieurs magasins (PostgreSQL + Elasticsearch + MinIO pour products).
- Config : Consul KV sous
config/<service>/...; secrets : Vault soussecret/data/...; chargement au démarrage dans le code (Python : VaultClient + ConsulConfigClient, puis construction de l'URL de base et des paramètres Keycloak). - Auth : Keycloak émet les JWT ; l’auth-proxy les valide et renvoie X-User-Id, X-User-Username, X-User-Email ; les backends lisent ces headers et ne gèrent jamais le JWT.
Accès au repository
Le projet Djassa est actuellement en développement et son code source reste privé.
Si vous souhaitez consulter le repository, vous pouvez me contacter en me partageant votre identifiant GitHub : 👉 www.linkedin.com/in/tiemoko-souahamai-thierry-hema-14a316175.
메타데이터
- post_id
- d8faab44b05c
- slug
- architecture-microservices-en-pratique-2-2-données-config-et-auth-jwt-forward-auth-d8faab44b05c
- url
- https://medium.com/@hemathierry/architecture-microservices-en-pratique-2-2-donn%C3%A9es-config-et-auth-jwt-forward-auth-d8faab44b05c
- canonical_url
- https://medium.com/@hemathierry/architecture-microservices-en-pratique-2-2-donn%C3%A9es-config-et-auth-jwt-forward-auth-d8faab44b05c
- author_url
- https://medium.com/@hemathierry
- status
- ok
- fetched_at
- 2026-07-11 01:37:02