Коротко. При взаимной TLS-аутентификации (mutual TLS, mTLS) сервер отправляет CertificateRequest, а клиент отвечает цепочкой сертификатов и подписью данных рукопожатия, которая доказывает, что у клиента есть закрытый ключ. Сервер проверяет эту цепочку по своему собственному якорю доверия — частному центру сертификации (certificate authority, CA) — и затем определяет по сертификату удостоверение клиента: в идеале это единственный URI SAN (альтернативное имя субъекта типа URI), например SPIFFE ID. Важно соблюсти 4 условия: клиент должен отправлять свой промежуточный сертификат (или промежуточный сертификат должен быть у сервера), сервер доверяет только тому CA, который выпускает клиентские сертификаты, авторизация выполняется после аутентификации (действительный сертификат сам по себе не даёт прав), а сертификаты живут недолго и продлеваются автоматически. Не используйте для клиентов публичные TLS-сертификаты: публичные иерархии отказываются от clientAuth.
Тестовый стенд работал в образах с зафиксированными версиями: 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 и node:24.22.0-bookworm-slim. Все ключи и сертификаты сгенерированы специально для него.
Где подходит mTLS
mTLS аутентифицирует машину, рабочую нагрузку или устройство, у которых есть ключ, на уровне соединения, ещё до разбора какого-либо HTTP-запроса. Поэтому mTLS хорошо подходит для:
- трафика между сервисами внутри платформы, где сервисная сетка (service mesh) или sidecar (Istio, Linkerd, Consul, отдельный Envoy) выпускает и ротирует сертификаты для каждой рабочей нагрузки;
- API для партнёров и B2B, где каждый партнёр получает сертификат от вашего CA, а профиль безопасности FAPI 2.0 допускает mTLS и для аутентификации клиентов (наряду с
private_key_jwt), и для привязки токенов доступа (наряду с DPoP); - устройств и агентов, которые могут хранить ключ аппаратно (в TPM или защищённом элементе) и предъявлять его при каждом соединении.
Для браузеров mTLS подходит плохо: пользователи видят окна выбора сертификата, а CDN или балансировщик нагрузки, который завершает TLS перед вашим сервером, сам должен поддерживать клиентские сертификаты. Если соединение не сквозное, через промежуточные узлы лучше проходят доказательства на уровне запроса, такие как DPoP или HTTP Message Signatures (см. конец этой страницы).
Что добавляется в рукопожатие
В TLS 1.3 (RFC 9846, который в июле 2026 года заменил RFC 8446) сообщение CertificateRequest от сервера идёт сразу после EncryptedExtensions. Оно обязано содержать signature_algorithms и может перечислять в certificate_authorities имена CA, которые сервер принимает (§ 4.4.2). Клиент отвечает сообщениями Certificate, CertificateVerify (подпись клиента по транскрипту рукопожатия) и Finished. Если клиент не отправил ни одного сертификата, сервер, согласно § 4.5.1.3, «MAY» (может) «по своему усмотрению либо продолжить рукопожатие без аутентификации клиента, либо прервать рукопожатие оповещением certificate_required».
openssl s_client показывает, что именно запрашивает сервер. Вот вывод для сервера nginx, описанного ниже:
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:...
На практике из этого следуют 2 вещи. Во-первых, клиенты выбирают сертификат по этому списку CA, и некоторые отказываются отправлять сертификат, цепочка которого не ведёт ни к одному из перечисленных имён (так поступает Go, см. ниже). Во-вторых, в TLS 1.3 клиент отправляет свой сертификат после того, как получил Finished от сервера, поэтому с точки зрения клиента рукопожатие завершается раньше, чем сервер оценит сертификат. Из-за этого отказ проявляется при первом чтении на стороне клиента в виде оповещения, и именно поэтому curl выдаёт для него (56) ... SSL_read, а не ошибку рукопожатия.
Частный CA для клиентских сертификатов
Клиентские сертификаты выпускает CA, которым управляете вы сами (или который вы арендуете как управляемый частный CA), а не публичный CA. Публичный CA не может подтвердить, что «это наш обработчик биллинга», а публичные TLS-иерархии отказываются от аутентификации клиентов: с 15 марта 2027 года Chrome Root Program требует, чтобы сертификаты подписчиков содержали только serverAuth, а Let's Encrypt 8 июля 2026 года перестал выпускать сертификаты с clientAuth (см. Поля сертификата X.509).
Иерархия стенда: корневой CA, выпускающий CA с pathlen:0, серверный сертификат для api.internal.test и клиентские сертификаты, удостоверение которых задаёт URI SAN в формате SPIFFE:
[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
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
URI SAN даёт типизированное и однозначное удостоверение; X.509-SVID из SPIFFE требует, чтобы URI SAN был ровно один, и предписывает, что валидаторы «MUST reject» (обязаны отклонять) сертификаты, в которых их больше. Полноценный SVID в том виде, в каком его выпускает SPIRE, содержит в EKU (расширенном использовании ключа) ещё и serverAuth. Кроме worker-7, на стенде выпустили billing (тот же CA, другой SPIFFE ID), intruder (те же имя и ID, но подписан посторонним корневым CA), expired (действовал 1 сентября 2026 года) и worker-9 от второго выпускающего CA, который расположен на 2 уровня ниже корневого.
nginx
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";
}
}
Результаты curl --cert ... --key ... для каждого случая, затем журнал:
--- 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
На что обратить внимание:
- nginx завершает рукопожатие и отвечает HTTP 400 (No required SSL certificate was sent); внутри это нестандартные коды 496 (нет сертификата) и 495 (проверка не пройдена), которые с помощью
error_pageможно превратить в собственный ответ. - Причина видна только в журнале ошибок и только на уровне
info. С уровнемerror, который действует по умолчанию, вы не увидите ничего. - Один конечный сертификат не проходит проверку. Сервер доверяет корневому CA; без промежуточного сертификата цепочку не построить, отсюда ошибка 21. Либо клиент отправляет
leaf + intermediate(параметр--certу curl принимает файл с цепочкой), либо сервер добавляет промежуточный сертификат в свой файл CA. ssl_verify_depthсчитает промежуточные сертификаты. Значение по умолчанию — 1. Мы повторили прогон с глубиной 1:worker-7(1 промежуточный сертификат) по-прежнему прошёл проверку, аworker-9(2 промежуточных) не прошёл с ошибкой(20:unable to get local issuer certificate)и прошёл при глубине 2.ssl_client_certificateотправляет клиентам список своих CA, аssl_trusted_certificateпроверяет сертификаты по такому же файлу, не сообщая его клиентам (документация nginx). Используйте первую директиву, если клиентам нужна эта подсказка, чтобы выбрать сертификат.
Чтобы передать удостоверение дальше, proxy_set_header X-Client-Cert $ssl_client_escaped_cert; пересылает PEM в URL-кодировке, а $ssl_client_verify принимает значения SUCCESS, FAILED:reason или NONE. При ssl_verify_client optional проверяйте $ssl_client_verify сами, прежде чем проксировать запрос.
Envoy
Envoy умеет ещё и сверять удостоверение во время рукопожатия и передавать его приложению в заголовке x-forwarded-client-cert (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 }
Бэкенд на стенде — второй слушатель (listener) Envoy, который возвращает в ответе полученный заголовок XFCC. Результаты curl 7.81.0 из образа Envoy, затем статистика слушателя Envoy:
--- 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
Важен второй запрос: клиент отправил собственный заголовок XFCC, выдавая себя за admin, а SANITIZE_SET заменил этот заголовок данными, которые проверил сам прокси. Любой прокси, который передаёт удостоверение клиента в заголовке, должен удалять входящую копию этого заголовка, а приложение должно принимать такой заголовок только от прокси. Каждому отказу соответствует своё оповещение (certificate required, unknown ca, certificate unknown при несовпадении SAN) и отдельный счётчик; в рабочей среде первым делом смотрите на эти счётчики.
Go
Проверку выполняет tls.RequireAndVerifyClientCert с пулом ClientCAs, а авторизация по SPIFFE ID — обычный код обработчика:
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, "", "")
}
Клиент из той же программы задаёт tls.Config.Certificates и вызывает /jobs с каждым сертификатом; ещё в одном случае он с помощью GetClientCertificate отправляет сертификат безусловно:
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
Клиент Go вообще не отправил сертификат intruder: ни один сертификат в его цепочке не выпущен CA из списка certificate_authorities сервера, поэтому клиент ответил пустым сообщением Certificate, и сервер записал «client didn't provide a certificate». Если правильно настроенный клиент Go выдаёт certificate required, первым делом сравните издателей его цепочки со списком CA сервера. Когда сертификат отправили принудительно, его проверка завершилась ошибкой unknown certificate authority. На billing видно разделение аутентификации (сертификат действителен) и авторизации (403).
Node.js
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",
});
На стенде те же 5 запросов отправили серверу с rejectUnauthorized: true и серверу с false:
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
При rejectUnauthorized: true Node отклоняет сертификат, не прошедший проверку, просто закрывая сокет: ни оповещения, ни причины ни на одной из сторон, только ECONNRESET. OpenSSL обнаруживает отсутствие сертификата раньше, и клиент получает полноценное оповещение certificate required (на сервере — peer did not return a certificate). При false принимается любое соединение, а причину содержит socket.authorizationError; этот режим безопасен, только если на каждом пути обработки запроса сначала проверяется socket.authorized. В рабочей среде оставляйте true, а false с такой проверкой используйте на диагностическом порту, когда нужно узнать причину.
Выпуск и ротация клиентских сертификатов
- Короткий срок действия, автоматическое продление. Клиентские сертификаты сервисов следует выпускать на срок от нескольких часов до нескольких дней и продлевать с помощью агента, а не вручную: SPIRE для SPIFFE ID, cert-manager в Kubernetes, step-ca, PKI secrets engine в HashiCorp Vault или облачный частный CA. Однодневный сертификат редко приходится отзывать; если это всё же нужно, nginx может проверять список отзыва (CRL) с помощью
ssl_crl. - Перекрытие на каждом шаге. Продлевайте сертификат, когда пройдёт примерно две трети срока его действия, и дайте клиенту загружать новый сертификат без перезапуска (Envoy и SPIRE используют для этого API SDS, а в Go текущий файл можно читать в
GetClientCertificate). - Ротация CA. Сначала добавьте новый корневой сертификат в файл доверенных сертификатов на каждом сервере, затем начните выпускать сертификаты от него, а старый корневой сертификат удалите, когда истечёт срок действия последнего выпущенного от него сертификата. Серверы доверяют пакету сертификатов, поэтому оба корневых сертификата могут действовать одновременно.
- Отдельный CA для каждой задачи. CA, который выпускает клиентские сертификаты для одного API, не следует использовать для выпуска сертификатов другой системы: иначе серверы той системы будут принимать и сертификаты, выпущенные для первого API. Вторая линия защиты — проверка SAN в Envoy или ваша собственная таблица авторизации.
Частые ошибки
- Доверие системному хранилищу при проверке клиентов.
ssl_client_certificate /etc/ssl/certs/ca-certificates.crtпринимает любой сертификат любого публичного CA, а публичный сертификат ничего не говорит о ваших клиентах. - Аутентификация без авторизации. Любой действительный сертификат от вашего CA получает доступ, если нет правила, которое определяет, какое удостоверение что может вызывать, — как показал
billing. - Нестрогое сравнение имён. Сравнивайте разобранный URI SAN на точное совпадение; не ищите
CN=workerв строке субъекта и не сравнивайте префикс значения, которое выбирает злоумышленник. - Доверие заголовку с удостоверением клиента от кого угодно. Устанавливать такой заголовок может только прокси, на котором завершается mTLS, и этот прокси должен удалять входящую копию (в Envoy —
SANITIZE_SET, в nginx заголовок перезаписываетproxy_set_header). - Клиентский сертификат без
clientAuth. Если в сертификате есть EKU, но безclientAuth, серверы на основе OpenSSL его отклоняют: когда серверный сертификат стенда, у которого есть толькоserverAuth, предъявили как клиентский, nginx записал в журнал(26:unsuitable certificate purpose)— это ошибка 26 OpenSSL. - Балансировщики нагрузки перед сервером, которые завершают TLS. Сертификат проверяется на первом TLS-эндпоинте. Если это балансировщик нагрузки, он должен сам выполнять mTLS и передавать удостоверение дальше либо пропускать TCP-трафик без изменений.
В OAuth RFC 8705 опирается на mTLS дважды: клиенты аутентифицируются на эндпоинте токенов с помощью сертификата (tls_client_auth), а токены доступа можно привязать к этому сертификату (cnf с x5t#S256 — отпечатком SHA-256), так что украденный токен бесполезен без ключа. Там, где mTLS не может быть сквозным, DPoP даёт похожую привязку на уровне HTTP, а HTTP Message Signatures подписывают отдельные запросы.
Источники
- RFC 9846: TLS 1.3: § 4.4.2 (CertificateRequest), § 4.5.1.3 (получение сообщения Certificate), § 6 (оповещения)
- nginx ngx_http_ssl_module:
ssl_verify_client,ssl_verify_depth,ssl_client_certificate,ssl_trusted_certificate, ошибки 495 и 496 - Envoy: транспортный сокет TLS и x-forwarded-client-cert
- Go crypto/tls; Node.js tls.createServer
- SPIFFE X.509-SVID
- RFC 8705: взаимная TLS-аутентификация клиентов в OAuth 2.0 и токены доступа, привязанные к сертификатам
- Политика Chrome Root Program, версия 1.8; Let's Encrypt, прекращение поддержки сертификатов для аутентификации клиентов TLS в 2026 году