Zum Inhalt springen
ecdsa.com
← Alle Artikel

Mutual TLS für APIs: Client-Zertifikate in der Praxis

Eine private CA, fünf Client-Zertifikate und vier Server. Jede Konfiguration auf dieser Seite lief am 20. Oktober 2026, und jede Fehlermeldung ist genau die, die nginx 1.30, Envoy 1.39, Go 1.27 und Node.js 24 tatsächlich ausgegeben haben.

Kurz gesagt. Bei Mutual TLS (mTLS) sendet der Server einen CertificateRequest, und der Client antwortet mit seiner Zertifikatskette und einer Signatur über den Handshake, mit der er beweist, dass er den privaten Schlüssel besitzt. Der Server validiert diese Kette gegen seinen eigenen Vertrauensanker, eine private CA, und ordnet das Zertifikat dann einer Identität zu, idealerweise einem einzigen URI-SAN wie einer SPIFFE-ID. Vier Dinge müssen stimmen: Der Client muss sein Zwischenzertifikat mitsenden (oder der Server muss es haben), der Server vertraut nur der CA, die Client-Zertifikate ausstellt, die Autorisierung folgt erst auf die Authentifizierung (ein gültiges Zertifikat ist noch keine Berechtigung), und Zertifikate sind kurzlebig und werden automatisch erneuert. Verwende für Clients keine öffentlichen TLS-Zertifikate: Die öffentlichen Hierarchien streichen clientAuth.

Der Testaufbau lief in fest versionierten Images: 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 und node:24.22.0-bookworm-slim. Alle Schlüssel und Zertifikate wurden eigens dafür erzeugt.

Wofür sich mTLS eignet

mTLS authentifiziert Maschinen, Workloads oder Geräte, die einen Schlüssel besitzen, auf Verbindungsebene, noch bevor eine HTTP-Anfrage geparst wird. Damit eignet es sich gut für:

  • Service-to-Service-Verkehr innerhalb einer Plattform, bei dem ein Service Mesh oder Sidecar (Istio, Linkerd, Consul, Envoy pur) für jede Workload Zertifikate ausstellt und rotiert.
  • Partner- und B2B-APIs, bei denen jeder Partner ein Zertifikat von deiner CA bekommt; das Sicherheitsprofil FAPI 2.0 akzeptiert mTLS sowohl für die Client-Authentifizierung (neben private_key_jwt) als auch für das Binden von Access Tokens (neben DPoP).
  • Geräte und Agenten, die einen Schlüssel in Hardware halten können (einem TPM, einem Secure Element) und ihn bei jeder Verbindung vorweisen.

Für Browser eignet es sich schlecht: Dort erscheinen Dialoge zur Zertifikatsauswahl, und ein CDN oder Load Balancer, der vor deinem Server TLS terminiert, muss selbst Client-Zertifikate unterstützen. Reicht die Verbindung nicht von Ende zu Ende, kommen Nachweise auf Anfrageebene wie DPoP oder HTTP Message Signatures besser ans Ziel (siehe Ende dieser Seite).

Was im Handshake hinzukommt

In TLS 1.3 (RFC 9846, seit Juli 2026 Nachfolger von RFC 8446) folgt der CertificateRequest des Servers direkt auf EncryptedExtensions. Er muss signature_algorithms enthalten und kann in certificate_authorities die CA-Namen auflisten, die der Server akzeptiert (§4.4.2). Der Client antwortet mit Certificate, CertificateVerify (seiner Signatur über das Transkript) und Finished. Sendet der Client nichts, „KANN [der Server] nach eigenem Ermessen den Handshake entweder ohne Client-Authentifizierung fortsetzen oder ihn mit einem Alert certificate_required abbrechen“ (§4.5.1.3).

openssl s_client zeigt, was ein Server anfordert. Hier die Ausgabe für den nginx-Server aus dem Abschnitt weiter unten:

openssl s_client -connect 127.0.0.1:8443 -servername api.internal.test -CAfile root.pem (Auszug)
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:...

In der Praxis hat das zwei Folgen. Erstens wählen Clients anhand dieser CA-Liste ein Zertifikat aus, und manche weigern sich, eines zu senden, dessen Kette nicht zu einem der aufgeführten Namen führt (Go tut das, wie unten zu sehen ist). Zweitens sendet der Client in TLS 1.3 sein Zertifikat erst, nachdem er das Finished des Servers empfangen hat; aus Sicht des Clients ist der Handshake also abgeschlossen, bevor der Server das Zertifikat beurteilt hat. Ablehnungen zeigen sich deshalb erst beim ersten Lesezugriff des Clients, und zwar als Alert – darum meldet curl sie als (56) ... SSL_read und nicht als Handshake-Fehler.

Eine private CA für Client-Zertifikate

Client-Zertifikate kommen von einer CA, die du selbst betreibst (oder als verwaltete private CA mietest), nicht von einer öffentlichen. Öffentliche CAs können nicht bestätigen: „Das ist unser Billing-Worker.“ Außerdem schaffen die öffentlichen TLS-Hierarchien die Client-Authentifizierung ab: Das Chrome Root Program verlangt ab dem 15. März 2027 Subscriber-Zertifikate, die nur serverAuth enthalten, und Let's Encrypt hat am 8. Juli 2026 aufgehört, Zertifikate mit clientAuth auszustellen (siehe Felder von X.509-Zertifikaten).

Die Hierarchie im Testaufbau: eine Root-CA, eine ausstellende CA mit pathlen:0, ein Serverzertifikat für api.internal.test und Client-Zertifikate, deren Identität ein URI-SAN im SPIFFE-Format ist:

ext.cnf (Auszug): die ausstellende CA und ein Profil für Client-Zertifikate
[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 (Auszug): ein Client-Zertifikat mit einem Tag Laufzeit und seine Kettendatei ausstellen
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

Ein URI-SAN liefert eine typisierte, eindeutige Identität; X.509-SVID von SPIFFE verlangt genau einen und schreibt vor, dass Validatoren Zertifikate mit mehreren „ablehnen MÜSSEN“. Ein vollständiges SVID, wie SPIRE es ausstellt, setzt in der EKU zusätzlich serverAuth. Neben worker-7 hat der Testaufbau billing ausgestellt (dieselbe CA, andere SPIFFE-ID), intruder (derselbe Name und dieselbe ID, von einer fremden Root-CA signiert), expired (gültig am 1. September 2026) und worker-9 unter einer zweiten ausstellenden CA zwei Ebenen unterhalb der Root-CA.

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";
    }
}

Jeder Fall mit curl --cert ... --key ..., danach das Log:

curl-Ergebnisse, danach mtls.log (Zeitstempel und Client-Adressen entfernt)
--- 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

Was dabei auffällt:

  • nginx schließt den Handshake ab und antwortet mit HTTP 400 (No required SSL certificate was sent); intern sind das die nicht standardisierten Codes 496 (kein Zertifikat) und 495 (Prüfung fehlgeschlagen), die du mit error_page in eine eigene Antwort umwandeln kannst.
  • Den Grund nennt nur das Fehlerlog, und auch nur auf Level info. Mit dem Standardlevel error siehst du gar nichts.
  • Das Leaf-Zertifikat allein scheitert. Der Server vertraut dem Root-Zertifikat; ohne das Zwischenzertifikat gibt es keinen Pfad, daher Fehler 21. Entweder sendet der Client leaf + intermediate (--cert von curl akzeptiert eine Kettendatei), oder der Server nimmt das Zwischenzertifikat in seine CA-Datei auf.
  • ssl_verify_depth zählt Zwischenzertifikate. Der Standardwert ist 1. Wir haben den Test mit Tiefe 1 wiederholt: worker-7 (ein Zwischenzertifikat) kam weiterhin durch, worker-9 (zwei Zwischenzertifikate) scheiterte mit (20:unable to get local issuer certificate) und kam erst bei Tiefe 2 durch.
  • ssl_client_certificate sendet seine CA-Liste an die Clients; ssl_trusted_certificate prüft gegen dieselbe Art von Datei, ohne sie bekannt zu geben (nginx-Dokumentation). Nimm die erste Direktive, wenn Clients diesen Hinweis brauchen, um ein Zertifikat auszuwählen.

Um die Identität weiterzureichen, leitet proxy_set_header X-Client-Cert $ssl_client_escaped_cert; das URL-kodierte PEM weiter, und $ssl_client_verify ist SUCCESS, FAILED:reason oder NONE. Bei ssl_verify_client optional prüfe $ssl_client_verify selbst, bevor du die Anfrage an das Backend weiterleitest.

Envoy

Envoy kann die Identität außerdem schon während des Handshakes abgleichen und sie der Anwendung in x-forwarded-client-cert (XFCC) übergeben:

envoy.yaml (Auszug): mTLS-Listener mit SAN-Matcher und 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 }

Das Backend im Testaufbau ist ein zweiter Envoy-Listener, der den empfangenen XFCC-Header zurückgibt. Ergebnisse mit curl 7.81.0 aus dem Envoy-Image, danach die Listener-Statistiken von Envoy:

Ergebnisse von curl -i und /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

Die zweite Anfrage ist die entscheidende: Der Client hat einen eigenen XFCC-Header mitgeschickt, der ihn als admin ausgab, und SANITIZE_SET hat diesen Header durch das ersetzt, was der Proxy geprüft hat. Jeder Proxy, der die Client-Identität in einem Header weitergibt, muss die eingehende Kopie entfernen; eine Anwendung darf diesen Header nur vom Proxy akzeptieren. Jeder Fehlschlag entspricht einem Alert (certificate required, unknown ca, certificate unknown bei einem nicht erlaubten SAN) und einem eigenen Zähler – dort solltest du in Produktion zuerst nachsehen.

Go

tls.RequireAndVerifyClientCert übernimmt zusammen mit einem Pool in ClientCAs die Prüfung; die Autorisierung nach SPIFFE-ID ist gewöhnlicher Handler-Code:

main.go (Auszug): Serverseite (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, "", "")
}

Der Client desselben Programms setzt tls.Config.Certificates und ruft /jobs mit jedem Zertifikat auf; für einen zusätzlichen Fall sendet er über GetClientCertificate bedingungslos ein Zertifikat:

go run . (Ports durch PORT ersetzt)
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

Der Go-Client hat das Zertifikat intruder gar nicht erst gesendet: Keines der Zertifikate in seiner Kette wurde von einer CA aus der Liste certificate_authorities des Servers ausgestellt, also antwortete er mit einer leeren Nachricht Certificate, und der Server sah „client didn't provide a certificate“. Meldet ein korrekt konfigurierter Go-Client certificate required, vergleiche vor allem anderen die Aussteller seiner Kette mit der CA-Liste des Servers. Beim erzwungenen Senden scheiterte das Zertifikat mit unknown certificate authority. billing zeigt die Trennung zwischen Authentifizierung (das Zertifikat ist gültig) und Autorisierung (403).

Node.js

mtls.mjs (Auszug): Serveroptionen und Autorisierung (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",
});

Der Testaufbau hat dieselben fünf Aufrufe gegen einen Server mit rejectUnauthorized: true und einen mit false ausgeführt:

node mtls.mjs (Ortsangaben in OpenSSL-Fehlern gekürzt)
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

Mit rejectUnauthorized: true lehnt Node ein fehlerhaftes Zertifikat ab, indem es den Socket schließt: kein Alert, auf keiner Seite ein Grund, nur ECONNRESET. OpenSSL fängt ein fehlendes Zertifikat schon früher ab, und der Client bekommt einen ordentlichen Alert certificate required (peer did not return a certificate auf dem Server). Mit false wird jede Verbindung angenommen, und socket.authorizationError enthält den Grund; dieser Modus ist nur sicher, wenn jeder Anfragepfad zuerst socket.authorized prüft. Bleib in Produktion bei true und nutze false mit dieser Prüfung auf einem Diagnose-Port, wenn du den Grund brauchst.

Client-Zertifikate ausstellen und rotieren

  • Kurze Laufzeiten, automatische Erneuerung. Client-Zertifikate für Dienste sollten nur Stunden bis Tage gültig sein und von einem Agenten erneuert werden, nicht von einem Menschen: SPIRE für SPIFFE-IDs, cert-manager in Kubernetes, step-ca, die PKI-Engine von HashiCorp Vault oder eine private CA eines Cloud-Anbieters. Ein Zertifikat mit einem Tag Laufzeit muss selten widerrufen werden; falls doch, kann nginx mit ssl_crl eine CRL prüfen.
  • Überlappung bei jedem Schritt. Erneuere nach etwa zwei Dritteln der Laufzeit und lass den Client das neue Zertifikat ohne Neustart laden (Envoy und SPIRE nutzen dafür die SDS-API; Go kann über GetClientCertificate die jeweils aktuelle Datei lesen).
  • Die CA rotieren. Nimm die neue Root-CA zuerst in die Trust-Store-Datei jedes Servers auf, beginne dann, Zertifikate mit ihr auszustellen, und entferne die alte Root-CA, sobald das letzte von ihr ausgestellte Zertifikat abgelaufen ist. Server vertrauen einem Bundle, daher können beide Root-CAs gleichzeitig gültig sein.
  • Eine CA pro Zweck. Eine CA, die Client-Zertifikate für eine API ausstellt, sollte nicht zugleich für ein anderes System ausstellen, dessen Server diese Zertifikate dann akzeptieren würden. Die SAN-Matcher von Envoy oder deine eigene Autorisierungstabelle bilden die zweite Verteidigungslinie.

Häufige Fehler

  • Für Clients dem Trust Store des Systems vertrauen. ssl_client_certificate /etc/ssl/certs/ca-certificates.crt akzeptiert jedes Zertifikat jeder öffentlichen CA, und ein öffentliches Zertifikat sagt nichts über deine Clients aus.
  • Authentifizieren, ohne zu autorisieren. Jedes gültige Zertifikat deiner CA kommt durch, solange keine Regel festlegt, welche Identität was aufrufen darf – das hat billing gezeigt.
  • Namen ungenau abgleichen. Vergleiche einen geparsten URI-SAN exakt; durchsuche nicht den Subject-String nach CN=worker und gleiche nicht nur das Präfix eines Werts ab, den ein Angreifer gewählt hat.
  • Einem Header mit der Client-Identität von beliebiger Seite vertrauen. Nur der Proxy, der mTLS terminiert hat, darf ihn setzen, und er muss die eingehende Kopie entfernen (Envoy: SANITIZE_SET; nginx: proxy_set_header überschreibt sie).
  • Ein Client-Zertifikat ohne clientAuth. Hat das Zertifikat eine EKU ohne clientAuth, lehnen OpenSSL-basierte Server es ab: Als wir das Serverzertifikat des Testaufbaus, das nur serverAuth enthält, als Client-Zertifikat vorlegten, protokollierte nginx (26:unsuitable certificate purpose), also den Fehler 26 von OpenSSL.
  • TLS-terminierende Load Balancer vor dem Server. Die Zertifikatsprüfung findet am ersten TLS-Endpunkt statt. Ist das ein Load Balancer, muss er selbst mTLS durchführen und die Identität weitergeben oder TCP unverändert durchreichen.

Für OAuth baut RFC 8705 gleich zweifach auf mTLS auf: Clients authentifizieren sich am Token-Endpunkt mit einem Zertifikat (tls_client_auth), und Access Tokens lassen sich an dieses Zertifikat binden (cnf mit x5t#S256, dem SHA-256-Thumbprint), sodass ein gestohlenes Token ohne den Schlüssel wertlos ist. Wo mTLS nicht von Ende zu Ende reicht, bietet DPoP eine ähnliche Bindung auf der HTTP-Ebene, und HTTP Message Signatures signieren einzelne Anfragen.

Quellen