跳到主要内容
ecdsa.com
← 全部文章

API 双向 TLS(mTLS):客户端证书实战

一个私有 CA、五张客户端证书、四种服务器。下面的每份配置都在2026年10月20日实际运行过,每条错误信息都是 nginx 1.30、Envoy 1.39、Go 1.27 和 Node.js 24 实际输出的原文。

简短回答:在双向 TLS(mTLS)中,服务器发送 CertificateRequest,客户端回以自己的证书链和对握手内容的签名,以此证明它持有私钥。服务器用它自己的信任锚(一个私有 CA)验证这条证书链,然后把证书映射为一个身份,最好是单个 URI SAN,例如 SPIFFE ID。有四件事必须做对:客户端必须发送自己的中间证书(或者服务器必须持有它);服务器只信任签发客户端证书的那个 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 请求之前。因此它很适合以下场景:

  • 平台内部的服务间流量,由服务网格或 sidecar(Istio、Linkerd、Consul、单独使用的 Envoy)为每个工作负载签发并轮换证书。
  • 合作伙伴和 B2B API,每个合作伙伴从你的 CA 获得一张证书;FAPI 2.0 安全规范既接受用 mTLS 做客户端身份验证(与 private_key_jwt 并列),也接受用它绑定访问令牌(与 DPoP 并列)。
  • 设备和代理程序(agent),它们能把密钥保存在硬件中(TPM、安全元件),并在每次连接时出示该密钥。

它不太适合浏览器:用户会看到证书选择框,而且部署在你前面、负责终结 TLS 的 CDN 或负载均衡器本身必须支持客户端证书。如果连接不是端到端的,DPoP 或 HTTP 消息签名这类请求级证明更容易穿过中间环节(见本页末尾)。

握手中多了什么

在 TLS 1.3(RFC 9846,2026年7月取代了 RFC 8446)中,服务器的 CertificateRequest 紧跟在 EncryptedExtensions 之后。它必须携带 signature_algorithms,并且可以在 certificate_authorities 中列出它接受的 CA 名称(§4.4.2)。客户端回复 Certificate、CertificateVerify(它对握手记录的签名)和 Finished。如果客户端什么都不发送,服务器“可以(MAY)自行决定:要么在没有客户端身份验证的情况下继续握手,要么以 certificate_required 告警中止握手”(§4.5.1.3)。

openssl s_client 可以显示服务器要求了什么。以下是下文 nginx 服务器的输出:

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

这在实践中有两个重要影响。第一,客户端会根据这份 CA 列表选择证书,有些客户端不肯发送无法通过证书链追溯到列表中某个名称的证书(Go 就是这样,见下文)。第二,在 TLS 1.3 中,客户端是在收到服务器的 Finished 之后才发送证书的,因此从客户端的角度看,服务器还没有判定证书,握手就已经完成了。所以拒绝会以告警的形式出现在客户端的第一次读取中,这也是 curl 把它们报告为 (56) ... SSL_read、而不是握手错误的原因。

为客户端证书搭建私有 CA

客户端证书来自你自己运营的 CA(或者租用的托管私有 CA),而不是公共 CA。公共 CA 无法核实“这是我们的计费工作进程”,而且公共 TLS 证书体系正在取消客户端身份验证:Chrome 根证书计划要求自2027年3月15日起订户证书只能包含 serverAuth,Let's Encrypt 已于2026年7月8日停止签发带 clientAuth 的证书(参见 X.509 证书字段)。

实验中的证书层级:一个根 CA、一个带 pathlen:0 的签发 CA、一张用于 api.internal.test 的服务器证书,以及若干客户端证书,它们的身份是 SPIFFE 格式的 URI SAN:

ext.cnf(节选):签发 CA 与客户端证书模板
[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(节选):签发有效期一天的客户端证书及其证书链文件
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 提供有类型、无歧义的身份;SPIFFE 的 X.509-SVID 要求恰好有一个 URI SAN,并规定验证器对包含多个 URI SAN 的证书“必须(MUST)拒绝”。SPIRE 签发的完整 SVID 还会在 EKU 中设置 serverAuth。除 worker-7 外,实验还签发了 billing(同一个 CA,不同的 SPIFFE ID)、intruder(名称和 ID 相同,但由一个无关的根签名)、expired(有效期为2026年9月1日)以及 worker-9(由第二个签发 CA 签发,该 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";
    }
}

每种情况都用 curl --cert ... --key ... 测试,然后查看日志:

curl 结果,然后是 mtls.log(已删除时间戳和客户端地址)
--- 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 级别时什么也看不到。
  • 只发送叶子证书会失败。服务器信任的是根;没有中间证书就构建不出路径,因此出现错误 21。要么客户端发送 leaf + intermediate(curl 的 --cert 接受证书链文件),要么服务器把中间证书加入它的 CA 文件。
  • ssl_verify_depth 计算的是中间证书的数量。默认值为 1。我们用深度 1 重新运行了一遍:worker-7(一个中间证书)仍然通过;worker-9(两个中间证书)验证失败,报出 (20:unable to get local issuer certificate),深度设为 2 时才通过。
  • ssl_client_certificate 会把其中的 CA 列表发给客户端;ssl_trusted_certificate 用同类文件做验证,但不公布其中的 CA(nginx 文档)。如果客户端需要这个提示来选择证书,就用前者。

要把身份传给后端,proxy_set_header X-Client-Cert $ssl_client_escaped_cert; 会转发经过 URL 编码的 PEM,而 $ssl_client_verify 的值为 SUCCESS、FAILED:reason 或 NONE。如果使用 ssl_verify_client optional,就要在代理转发之前自行检查 $ssl_client_verify。

Envoy

Envoy 还能在握手期间匹配身份,并通过 x-forwarded-client-cert(XFCC)把身份交给应用:

envoy.yaml(节选):带 SAN 匹配器和 XFCC 的 mTLS 监听器
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 }

实验中的后端是第二个 Envoy 监听器,它会回显收到的 XFCC 请求头。下面是用 Envoy 镜像中的 curl 7.81.0 得到的结果,然后是 Envoy 的监听器统计:

curl -i 结果和 /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

关键是第二个请求:客户端发送了自己的 XFCC 请求头,声称自己是 admin,而 SANITIZE_SET 把它替换成了代理验证过的内容。任何通过请求头转发客户端身份的代理都必须删除请求中原有的这个请求头;应用也必须只接受来自代理的这个请求头。每种失败都对应一个告警(certificate required、unknown ca,SAN 不匹配时为 certificate unknown)和一个单独的计数器,在生产环境中应当首先查看这些计数器。

Go

tls.RequireAndVerifyClientCert 加上 ClientCAs 证书池负责验证;按 SPIFFE ID 授权只是普通的处理函数代码:

main.go(节选):服务端(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, "", "")
}

同一程序中的客户端设置 tls.Config.Certificates,并分别用每张证书调用 /jobs;另外还有一个用例,用 GetClientCertificate 无条件地发送证书:

go run .(端口已替换为 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

Go 客户端根本没有发送 intruder 证书:它的证书链中没有一张证书是由服务器 certificate_authorities 列表中的 CA 签发的,所以它回复了一条空的 Certificate 消息,服务器看到的是“client didn't provide a certificate”。如果配置正确的 Go 客户端报告 certificate required,首先要把它证书链中的颁发者与服务器的 CA 列表做比较。强制发送时,这张证书的验证失败,报出 unknown certificate authority。billing 展示了身份验证(证书有效)与授权(403)之间的区别。

Node.js

mtls.mjs(节选):服务器选项与授权(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",
});

实验对 rejectUnauthorized: true 的服务器和 false 的服务器分别执行了同样的五次调用:

node mtls.mjs(已缩短 OpenSSL 错误位置信息)
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 并加上这项检查。

客户端证书的签发与轮换

  • 有效期短,自动续期。服务使用的客户端证书有效期应为几小时到几天,并且由代理程序自动续期,而不是由人手动续期:SPIFFE ID 用 SPIRE,Kubernetes 中用 cert-manager,此外还有 step-ca、HashiCorp Vault 的 PKI 引擎或云上的私有 CA。有效期一天的证书很少需要吊销;如果确实需要,nginx 可以用 ssl_crl 检查 CRL。
  • 每一步都留出重叠期。在有效期过去约三分之二时续期,并让客户端无需重启就能加载新证书(Envoy 和 SPIRE 为此使用 SDS API;Go 可以用 GetClientCertificate 读取当前文件)。
  • 轮换 CA。先把新根加入每台服务器的信任文件,再开始用它签发证书,等旧根签发的最后一张证书过期后,再删除旧根。服务器信任的是一个证书包,所以两个根可以同时有效。
  • 一个用途一个 CA。为某个 API 签发客户端证书的 CA 不应同时为另一个系统签发证书,否则那个系统的服务器也会接受这些证书。Envoy 的 SAN 匹配器或你自己的授权映射表是第二道防线。

常见错误

  • 用系统证书库作为客户端证书的信任来源。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),即 OpenSSL 的错误 26。
  • 服务器前面有终结 TLS 的负载均衡器。证书检查发生在第一个 TLS 端点上。如果这个端点是负载均衡器,它就必须执行 mTLS 并转发身份,或者原样透传 TCP。

在 OAuth 中,RFC 8705 在两处用到了 mTLS:客户端用证书向令牌端点做身份验证(tls_client_auth),访问令牌也可以绑定到这张证书(cnf 中的 x5t#S256,即 SHA-256 指纹),这样被盗的令牌没有密钥就毫无用处。在 mTLS 无法做到端到端的地方,DPoP 在 HTTP 层提供类似的绑定,而 HTTP 消息签名则对单个请求签名。

参考资料