Aller au contenu
ecdsa.com
← Tous les articles

TLS mutuel pour les API : certificats clients en pratique

Une AC privée, cinq certificats clients et quatre serveurs. Chaque configuration présentée ci-dessous a été exécutée le 20 octobre 2026, et chaque message d’erreur est celui qu’ont réellement affiché nginx 1.30, Envoy 1.39, Go 1.27 et Node.js 24.

En bref. Dans le TLS mutuel (mTLS), le serveur envoie un CertificateRequest, et le client répond avec sa chaîne de certificats et une signature portant sur le handshake, qui prouve qu’il détient la clé privée. Le serveur valide cette chaîne à partir de sa propre ancre de confiance, une autorité de certification (AC) privée, puis associe le certificat à une identité, idéalement un SAN URI unique tel qu’un SPIFFE ID. Veillez à quatre points : le client doit envoyer son certificat intermédiaire (ou le serveur doit en disposer), le serveur ne fait confiance qu’à l’AC qui émet les certificats clients, l’autorisation intervient après l’authentification (un certificat valide ne vaut pas permission), et les certificats ont une courte durée de vie et sont renouvelés automatiquement. N’utilisez pas de certificats TLS publics pour les clients : les hiérarchies publiques abandonnent clientAuth.

Le laboratoire a tourné dans des images aux versions figées : debian:forky-20261005-slim (OpenSSL 3.6.5, nginx 1.30.4, curl 8.22.0), envoyproxy/envoy:v1.39.4, golang:1.27.3-trixie et node:24.22.0-bookworm-slim. Toutes les clés et tous les certificats ont été générés pour l’occasion.

Quand utiliser mTLS

mTLS authentifie une machine, une charge de travail ou un appareil qui détient une clé, au niveau de la connexion, avant même qu’une requête HTTP soit analysée. Il convient donc bien aux cas suivants :

  • Le trafic de service à service au sein d’une plateforme, où un maillage de services (service mesh) ou un sidecar (Istio, Linkerd, Consul, Envoy seul) émet les certificats de chaque charge de travail et en assure la rotation.
  • Les API de partenaires et B2B, où chaque partenaire reçoit un certificat de votre AC ; le profil de sécurité FAPI 2.0 accepte mTLS à la fois pour l’authentification du client (aux côtés de private_key_jwt) et pour la liaison des jetons d’accès (aux côtés de DPoP).
  • Les appareils et les agents capables de conserver une clé dans du matériel (un TPM, un élément sécurisé) et de la présenter à chaque connexion.

Il convient mal aux navigateurs : les utilisateurs voient des sélecteurs de certificat, et un CDN ou un répartiteur de charge qui termine TLS devant vous doit lui-même prendre en charge les certificats clients. Lorsque la connexion n’est pas de bout en bout, des preuves au niveau de la requête, comme DPoP ou HTTP Message Signatures, passent mieux (voir la fin de cette page).

Ce qu’ajoute le handshake

En TLS 1.3 (RFC 9846, qui a remplacé la RFC 8446 en juillet 2026), le CertificateRequest du serveur arrive juste après EncryptedExtensions. Il doit contenir signature_algorithms et peut lister dans certificate_authorities les noms d’AC qu’il accepte (§4.4.2). Le client répond avec Certificate, CertificateVerify (sa signature sur la transcription du handshake) et Finished. Si le client n’envoie rien, le serveur « PEUT, à sa discrétion, soit poursuivre le handshake sans authentification du client, soit interrompre le handshake avec une alerte certificate_required » (§4.5.1.3).

openssl s_client montre ce que demande un serveur. Avec le serveur nginx présenté plus bas :

openssl s_client -connect 127.0.0.1:8443 -servername api.internal.test -CAfile root.pem (extrait)
Acceptable client certificate CA names
O=Example, CN=Example Internal Root
Requested Signature Algorithms: id-ml-dsa-65:id-ml-dsa-87:id-ml-dsa-44:ECDSA+SHA256:ECDSA+SHA384:ECDSA+SHA512:ed25519:ed448:...

Deux conséquences comptent en pratique. D’abord, les clients se servent de cette liste d’AC pour choisir un certificat, et certains refusent d’envoyer un certificat dont la chaîne ne remonte pas à un nom de la liste (c’est le cas de Go, comme on le verra plus bas). Ensuite, en TLS 1.3, le client envoie son certificat après avoir reçu le Finished du serveur : du point de vue du client, le handshake est donc terminé avant que le serveur ait jugé le certificat. Les rejets apparaissent par conséquent lors de la première lecture du client, sous forme d’alerte, et c’est pourquoi curl les signale comme (56) ... SSL_read et non comme une erreur de handshake.

Une AC privée pour les certificats clients

Les certificats clients proviennent d’une AC que vous exploitez (ou que vous louez sous forme d’AC privée managée), et non d’une AC publique. Les AC publiques ne peuvent pas valider « ceci est notre worker de facturation », et les hiérarchies TLS publiques abandonnent l’authentification client : le Chrome Root Program exige des certificats d’abonné limités à serverAuth à partir du 15 mars 2027, et Let’s Encrypt a cessé d’émettre des certificats clientAuth le 8 juillet 2026 (voir les champs des certificats X.509).

La hiérarchie du laboratoire : une racine, une AC émettrice avec pathlen:0, un certificat serveur pour api.internal.test, et des certificats clients dont l’identité est un SAN URI au format SPIFFE :

ext.cnf (extrait) : l’AC émettrice et un profil de certificat client
[issuing]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, keyCertSign, cRLSign
extendedKeyUsage = serverAuth, clientAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid
[worker7]
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature
extendedKeyUsage = clientAuth
subjectAltName = URI:spiffe://example.test/ns/prod/sa/worker-7
authorityKeyIdentifier = keyid
pki.sh (extrait) : émettre un certificat client valable un jour et son fichier de chaîne
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out worker-7.key
openssl req -new -key worker-7.key -subj "/O=Example/CN=worker-7" -out worker-7.csr
openssl x509 -req -in worker-7.csr -CA issuing.pem -CAkey issuing.key -extfile ext.cnf -extensions worker7 -days 1 -out worker-7.pem
cat worker-7.pem issuing.pem > worker-7-chain.pem

Un SAN URI fournit une identité typée et sans ambiguïté ; le X.509-SVID de SPIFFE en exige exactement un et indique que les validateurs « DOIVENT rejeter » les certificats qui en portent davantage. Un SVID complet, tel que l’émet SPIRE, place aussi serverAuth dans l’EKU. Outre worker-7, le laboratoire a émis billing (même AC, SPIFFE ID différent), intruder (même nom et même ID, signé par une racine sans rapport), expired (valide le 1er septembre 2026) et worker-9, émis par une seconde AC émettrice située deux niveaux sous la racine.

nginx

/etc/nginx/conf.d/mtls.conf (nginx 1.30.4)
server {
    listen 8443 ssl;
    server_name api.internal.test;
    error_log /var/log/nginx/mtls.log info;   # client certificate errors are logged at "info"
    ssl_certificate     /w/pki/server-chain.pem;
    ssl_certificate_key /w/pki/server.key;

    ssl_client_certificate /w/pki/root.pem;   # trust anchor for client certificates
    ssl_verify_client      on;
    ssl_verify_depth       2;

    location / {
        default_type text/plain;
        return 200 "verify=$ssl_client_verify subject=$ssl_client_s_dn issuer=$ssl_client_i_dn\n";
    }
}

Les résultats de chaque cas avec curl --cert ... --key ..., puis le journal :

Résultats de curl, puis mtls.log (horodatages et adresses des clients supprimés)
--- no client certificate
<head><title>400 No required SSL certificate was sent</title></head>
--- worker-7, leaf only
<head><title>400 The SSL certificate error</title></head>
--- worker-7 with its intermediate
verify=SUCCESS subject=CN=worker-7,O=Example issuer=CN=Example Issuing CA 1,O=Example
--- certificate from another CA
<head><title>400 The SSL certificate error</title></head>
--- expired certificate
<head><title>400 The SSL certificate error</title></head>

[info] client sent no required SSL certificate while reading client request headers
[info] client SSL certificate verify error: (21:unable to verify the first certificate) while reading client request headers
[info] client SSL certificate verify error: (21:unable to verify the first certificate) while reading client request headers
[info] client SSL certificate verify error: (10:certificate has expired) while reading client request headers

À retenir :

  • nginx termine le handshake et répond par un HTTP 400 (No required SSL certificate was sent) ; en interne, il s’agit des codes non standard 496 (pas de certificat) et 495 (échec de la vérification), que error_page peut transformer en votre propre réponse.
  • La raison n’apparaît que dans le journal d’erreurs, et seulement au niveau info. Avec le niveau par défaut, error, vous ne voyez rien.
  • Le certificat feuille seul est rejeté. Le serveur fait confiance à la racine ; sans l’intermédiaire, il n’existe aucun chemin, d’où l’erreur 21. Soit le client envoie leaf + intermediate (l’option --cert de curl accepte un fichier de chaîne), soit le serveur ajoute l’intermédiaire à son fichier d’AC.
  • ssl_verify_depth compte les intermédiaires. La valeur par défaut est 1. Nous avons relancé les tests avec une profondeur de 1 : worker-7 (un intermédiaire) est toujours passé, worker-9 (deux intermédiaires) a échoué avec (20:unable to get local issuer certificate) et est passé avec une profondeur de 2.
  • ssl_client_certificate envoie sa liste d’AC aux clients ; ssl_trusted_certificate vérifie par rapport au même type de fichier sans l’annoncer (documentation nginx). Utilisez le premier lorsque les clients ont besoin de cette indication pour choisir un certificat.

Pour transmettre l’identité, proxy_set_header X-Client-Cert $ssl_client_escaped_cert; transfère le PEM encodé en URL, et $ssl_client_verify vaut SUCCESS, FAILED:reason ou NONE. Avec ssl_verify_client optional, vérifiez vous-même $ssl_client_verify avant de relayer la requête.

Envoy

Envoy peut aussi vérifier la correspondance de l’identité pendant le handshake et la transmettre à l’application dans x-forwarded-client-cert (XFCC) :

envoy.yaml (extrait) : listener mTLS avec correspondance de SAN et XFCC
transport_socket:
  name: envoy.transport_sockets.tls
  typed_config:
    "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
    require_client_certificate: true
    common_tls_context:
      tls_params: { tls_minimum_protocol_version: TLSv1_3 }
      tls_certificates:
      - certificate_chain: { filename: /w/pki/server-chain.pem }
        private_key: { filename: /w/pki/server.key }
      validation_context:
        trusted_ca: { filename: /w/pki/root.pem }
        match_typed_subject_alt_names:
        - san_type: URI
          matcher: { exact: "spiffe://example.test/ns/prod/sa/worker-7" }
# in the http_connection_manager of the same listener:
forward_client_cert_details: SANITIZE_SET
set_current_client_cert_details: { uri: true }

Dans le laboratoire, le backend est un second listener Envoy qui renvoie l’en-tête XFCC qu’il a reçu. Résultats avec le curl 7.81.0 de l’image Envoy, puis les statistiques du listener Envoy :

Résultats de curl -i et /stats (Envoy 1.39.4)
--- worker-7
HTTP/1.1 200 OK
x-backend-saw-xfcc: Hash=048c219f601a877b19c77fc8834750cdc6b1394b9080a50fa8eaeaf6f3793bd0;URI=spiffe://example.test/ns/prod/sa/worker-7
--- worker-7 sending its own x-forwarded-client-cert
HTTP/1.1 200 OK
x-backend-saw-xfcc: Hash=048c219f601a877b19c77fc8834750cdc6b1394b9080a50fa8eaeaf6f3793bd0;URI=spiffe://example.test/ns/prod/sa/worker-7
--- billing (valid certificate, SAN not allowed)
curl: (56) OpenSSL SSL_read: error:0A000416:SSL routines::sslv3 alert certificate unknown, errno 0
--- no client certificate
curl: (56) OpenSSL SSL_read: error:0A00045C:SSL routines::tlsv13 alert certificate required, errno 0
--- certificate from another CA
curl: (56) OpenSSL SSL_read: error:0A000418:SSL routines::tlsv1 alert unknown ca, errno 0

listener.127.0.0.1_10443.ssl.fail_verify_error: 1
listener.127.0.0.1_10443.ssl.fail_verify_no_cert: 1
listener.127.0.0.1_10443.ssl.fail_verify_san: 1
listener.127.0.0.1_10443.ssl.handshake: 2

La deuxième requête est la plus importante : le client a envoyé son propre en-tête XFCC en prétendant être admin, et SANITIZE_SET l’a remplacé par ce que le proxy a vérifié. Tout proxy qui transmet l’identité du client dans un en-tête doit supprimer la copie entrante ; une application ne doit accepter cet en-tête que de la part du proxy. Chaque échec correspond à une alerte (certificate required, unknown ca, certificate unknown en cas de SAN non conforme) et à un compteur distinct : c’est là qu’il faut regarder en premier en production.

Go

tls.RequireAndVerifyClientCert, avec un pool ClientCAs, se charge de la vérification ; l’autorisation par SPIFFE ID est du code ordinaire dans le gestionnaire HTTP :

main.go (extrait) : côté serveur (Go 1.27.3)
var allowed = map[string]string{"spiffe://example.test/ns/prod/sa/worker-7": "/jobs"}

func handler(w http.ResponseWriter, r *http.Request) {
	// With RequireAndVerifyClientCert, VerifiedChains[0][0] is the verified client leaf.
	leaf := r.TLS.VerifiedChains[0][0]
	if len(leaf.URIs) != 1 {
		http.Error(w, "client certificate must carry exactly one URI SAN", http.StatusForbidden)
		return
	}
	id := leaf.URIs[0].String()
	if allowed[id] != r.URL.Path {
		http.Error(w, id+" may not call "+r.URL.Path, http.StatusForbidden)
		return
	}
	fmt.Fprintf(w, "hello %s, cert valid until %s\n", id, leaf.NotAfter.Format(time.RFC3339))
}

func server(ln net.Listener) {
	cert, err := tls.LoadX509KeyPair(pki+"server-chain.pem", pki+"server.key")
	if err != nil {
		log.Fatal(err)
	}
	srv := &http.Server{
		Handler: http.HandlerFunc(handler),
		TLSConfig: &tls.Config{
			Certificates: []tls.Certificate{cert},
			ClientAuth:   tls.RequireAndVerifyClientCert,
			ClientCAs:    pool("root.pem"),
			MinVersion:   tls.VersionTLS13,
		},
		ErrorLog: log.New(os.Stdout, "server log: ", 0),
	}
	srv.ServeTLS(ln, "", "")
}

Le client du même programme définit tls.Config.Certificates et appelle /jobs avec chaque certificat ; pour un cas supplémentaire, il utilise GetClientCertificate afin d’envoyer un certificat sans condition :

go run . (ports remplacés par PORT)
server log: http: TLS handshake error from 127.0.0.1:PORT: tls: client didn't provide a certificate
no client certificate        client error: Get "https://127.0.0.1:PORT/jobs": remote error: tls: certificate required
worker-7                     200 hello spiffe://example.test/ns/prod/sa/worker-7, cert valid until 2026-10-21T15:17:06Z
billing                      403 spiffe://example.test/ns/prod/sa/billing may not call /jobs
server log: http: TLS handshake error from 127.0.0.1:PORT: tls: client didn't provide a certificate
certificate from another CA  client error: Get "https://127.0.0.1:PORT/jobs": remote error: tls: certificate required
server log: http: TLS handshake error from 127.0.0.1:PORT: tls: failed to verify certificate: x509: certificate signed by unknown authority
same, sent anyway            client error: Get "https://127.0.0.1:PORT/jobs": remote error: tls: unknown certificate authority
server log: http: TLS handshake error from 127.0.0.1:PORT: tls: failed to verify certificate: x509: certificate has expired or is not yet valid: current time 2026-10-20T15:18:44Z is after 2026-09-02T00:00:00Z
expired certificate          client error: Get "https://127.0.0.1:PORT/jobs": remote error: tls: expired certificate

Le client Go n’a pas du tout envoyé le certificat intruder : aucun des certificats de sa chaîne n’avait été émis par une AC de la liste certificate_authorities du serveur, il a donc répondu par un message Certificate vide, et le serveur a vu « client didn't provide a certificate ». Lorsqu’un client Go correctement configuré signale certificate required, comparez avant tout les émetteurs de sa chaîne avec la liste d’AC du serveur. Envoyé de force, le certificat a été rejeté avec l’erreur unknown certificate authority. billing montre la distinction entre authentification (le certificat est valide) et autorisation (403).

Node.js

mtls.mjs (extrait) : options du serveur et autorisation (Node.js 24.22.0)
function authorize(req, res) {
  // With rejectUnauthorized: false the handshake succeeds for any certificate (or none),
  // so this check is the only thing standing between the request and the handler.
  if (!req.socket.authorized) {
    res.writeHead(401).end(`client certificate rejected: ${req.socket.authorizationError}\n`);
    return;
  }
  const cert = req.socket.getPeerCertificate();
  // subjectaltname is a string such as "URI:spiffe://..." — parse it, do not substring-match it.
  const uris = (cert.subjectaltname ?? "").split(", ").filter((n) => n.startsWith("URI:")).map((n) => n.slice(4));
  const id = uris.length === 1 ? uris[0] : null;
  if (!id || ALLOWED.get(id) !== req.url) {
    res.writeHead(403).end(`${id ?? "no single URI SAN"} may not call ${req.url}\n`);
    return;
  }
  res.end(`hello ${id}, serial ${cert.serialNumber}\n`);
}

const serverOptions = (rejectUnauthorized) => ({
  key: pki("server.key"),
  cert: pki("server-chain.pem"),
  ca: pki("root.pem"), // trust anchor for client certificates
  requestCert: true, // send CertificateRequest
  rejectUnauthorized, // true: drop connections whose client certificate does not verify
  minVersion: "TLSv1.3",
});

Le laboratoire a effectué les cinq mêmes appels contre un serveur avec rejectUnauthorized: true et contre un autre avec false :

node mtls.mjs (emplacements des erreurs OpenSSL raccourcis)
node v24.22.0, OpenSSL 3.5.8
### rejectUnauthorized: true
  server tlsClientError: ERR_SSL_PEER_DID_NOT_RETURN_A_CERTIFICATE
no client certificate        client error: ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED: ...:tlsv13 alert certificate required:...:SSL alert number 116
worker-7                     200 hello spiffe://example.test/ns/prod/sa/worker-7, serial 7D2333CCB4C609E16E5592C959C71250AD4703BA
billing                      403 spiffe://example.test/ns/prod/sa/billing may not call /jobs
  server tlsClientError: ECONNRESET
certificate from another CA  client error: ECONNRESET: socket hang up
  server tlsClientError: ECONNRESET
expired certificate          client error: ECONNRESET: socket hang up
### rejectUnauthorized: false
no client certificate        401 client certificate rejected: UNABLE_TO_GET_ISSUER_CERT
worker-7                     200 hello spiffe://example.test/ns/prod/sa/worker-7, serial 7D2333CCB4C609E16E5592C959C71250AD4703BA
billing                      403 spiffe://example.test/ns/prod/sa/billing may not call /jobs
certificate from another CA  401 client certificate rejected: UNABLE_TO_VERIFY_LEAF_SIGNATURE
expired certificate          401 client certificate rejected: CERT_HAS_EXPIRED

Avec rejectUnauthorized: true, Node rejette un mauvais certificat en fermant le socket : aucune alerte, aucune raison d’un côté comme de l’autre, seulement ECONNRESET. Un certificat manquant est intercepté plus tôt par OpenSSL et reçoit une véritable alerte certificate required (peer did not return a certificate côté serveur). Avec false, toutes les connexions sont acceptées et socket.authorizationError contient la raison ; ce mode n’est sans danger que si chaque chemin de traitement des requêtes vérifie d’abord socket.authorized. Gardez true en production et utilisez false, avec cette vérification, sur un port de diagnostic lorsque vous avez besoin de la raison.

Émission et rotation des certificats clients

  • Durées de vie courtes, renouvellement automatisé. Les certificats clients des services devraient vivre de quelques heures à quelques jours et être renouvelés par un agent, pas par une personne : SPIRE pour les SPIFFE ID, cert-manager dans Kubernetes, step-ca, le moteur PKI de HashiCorp Vault ou une AC privée dans le cloud. Un certificat valable un jour a rarement besoin d’une révocation ; si c’est le cas, nginx peut vérifier une CRL avec ssl_crl.
  • Chevauchement à chaque étape. Renouvelez à environ deux tiers de la durée de vie, et laissez le client charger le nouveau certificat sans redémarrage (Envoy et SPIRE utilisent pour cela l’API SDS ; Go peut utiliser GetClientCertificate pour lire le fichier courant).
  • Rotation de l’AC. Ajoutez d’abord la nouvelle racine au fichier de confiance de chaque serveur, puis commencez à émettre à partir d’elle, puis retirez l’ancienne racine une fois que le dernier certificat qui en est issu a expiré. Les serveurs font confiance à un ensemble de certificats (bundle), si bien que les deux racines peuvent être valides en même temps.
  • Une AC par usage. Une AC qui émet des certificats clients pour une API ne devrait pas en émettre aussi pour un autre système, dont les serveurs accepteraient alors ces certificats. Les règles de correspondance de SAN d’Envoy ou votre propre table d’autorisation constituent la seconde ligne de défense.

Erreurs fréquentes

  • Faire confiance au magasin de certificats du système pour les clients. ssl_client_certificate /etc/ssl/certs/ca-certificates.crt accepte n’importe quel certificat de n’importe quelle AC publique, et un certificat public ne dit rien de vos clients.
  • Authentifier sans autoriser. Tout certificat valide émis par votre AC passe, à moins qu’une règle ne précise quelle identité peut appeler quoi, comme l’a montré billing.
  • Comparer les noms de façon approximative. Comparez exactement un SAN URI analysé ; ne cherchez pas CN=worker dans la chaîne du sujet et ne vous fiez pas au préfixe d’une valeur choisie par un attaquant.
  • Accepter d’où qu’il vienne un en-tête d’identité client. Seul le proxy qui a terminé mTLS peut le définir, et il doit supprimer la copie entrante (Envoy : SANITIZE_SET ; nginx : proxy_set_header l’écrase).
  • Un certificat client sans clientAuth. Si le certificat comporte un EKU sans clientAuth, les serveurs reposant sur OpenSSL le rejettent : présenter comme certificat client le certificat serveur du laboratoire, limité à serverAuth, a conduit nginx à journaliser (26:unsuitable certificate purpose), l’erreur 26 d’OpenSSL.
  • Des répartiteurs de charge qui terminent TLS devant le serveur. La vérification du certificat a lieu au premier point de terminaison TLS. Si c’est un répartiteur de charge, il doit effectuer lui-même mTLS et transmettre l’identité, ou bien laisser passer le TCP sans y toucher.

Pour OAuth, la RFC 8705 s’appuie deux fois sur mTLS : les clients s’authentifient auprès du point de terminaison de jeton avec un certificat (tls_client_auth), et les jetons d’accès peuvent être liés à ce certificat (cnf avec x5t#S256, l’empreinte SHA-256), si bien qu’un jeton volé est inutile sans la clé. Là où mTLS ne peut pas aller de bout en bout, DPoP offre une liaison similaire au niveau de la couche HTTP, et HTTP Message Signatures signe les requêtes une par une.

Sources