证书没问题,但对方只允许你带钥匙进门
去年给一个后台管理系统加 API 对接,对方的接口文档写得很客气:使用 HTTPS,双向认证(mTLS)。我把浏览器打开一试,普通 HTTPS 明明能打开,就以为"证书配好了"。结果第一次调接口返回的是一个没有任何提示的连接重置,日志里只有一行 SSL_connect returned=1 errno=0 state=error: certificate verify failed。
问题出在一个很多站长没分清的概念上。平时我们说的"上 HTTPS",是服务器出示证书、客户端验证它——这叫单向认证,锁住的是"我这个服务是真的"。而 mTLS 是双向的:客户端也要出示一张证书,服务器验过了才允许建立连接。前者解决的是"访问者不会被钓鱼",后者解决的是"服务器只跟被授权的人说话"。
这个协议本身很老,早在 TLS 1.0 之前就有,配置也不算复杂。麻烦的地方在于它的失败方式特别差:证书不对、链不全、名字不匹配、反向代理不透传,最后指向的现象大多是同一种——连接被重置。所以这篇文章按"先看清握手、再分开配两端、最后定位"的顺序写,重点放在各环节的报错判读上。
一、握手过程里,双向认证多了哪一步
把标准 TLS 握手拆开看,mTLS 只比单向认证多了一次"请求证书 / 出示证书"的往返,位置在接受完服务器证书之后、发送密钥材料之前。
ClientHello
<-- ServerHello, Certificate, CertificateRequest
ClientKeyExchange, Certificate, CertificateVerify, ChangeCipherSpec
<-- Finished
ChangeCipherSpec, Finished关键点有三条,配置时反复会用到:
- 服务器发
CertificateRequest,里面带着它信任的 CA 名单(certificate_authorities)。客户端听到这个才决定要不要出示证书。 - 客户端的证书必须由服务器信任的 CA 签发,或者直接用自签 CA 签——但自签 CA 必须显式告诉服务器"我信任它",系统默认的 CA store 里没有它。
- 客户端出示证书后还会发
CertificateVerify,用私钥签一段握手内容。所以客户端必须有私钥文件,光有证书不行。这一点在"证书给了开发同事但私钥留在运维手上"的分工里经常出问题。
还有一个容易被忽略的细节:PEM 里客户端证书和私钥的顺序。多数实现(OpenSSL、Nginx)对顺序不敏感,但有几家网关要求"证书在前、私钥在后"。如果配好之后一直报私钥不匹配,先检查文件里是不是把两段顺序颠倒或者漏了换行。
二、服务端:Nginx 的三行配置与两个必查项
Nginx 打开客户端验证只要三行,外加一处证书链:
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/nginx/ssl/api.crt;
ssl_certificate_key /etc/nginx/ssl/api.key;
# 关键三行
ssl_client_certificate /etc/nginx/ssl/client-ca.crt; # 信任的 CA 链
ssl_verify_client on; # on: 强制 / optional: 有就验
ssl_verify_depth 2; # 允许一级中间 CA
location / {
proxy_pass http://127.0.0.1:8080;
# 把认证结果传给后端
proxy_set_header X-SSL-Client-Verify $ssl_client_verify;
proxy_set_header X-SSL-Client-DN $ssl_client_s_dn;
}
}第一个必查项:ssl_client_certificate 指向的必须是签发客户端证书的那个 CA 证书,不是客户端证书本身。ssl_trusted_certificate 与它用途不同——前者会下发给客户端作为提示链,后者只本地校验用,不发送。把客户端证书直接写进 ssl_client_certificate 也能工作(自签场景),但只要客户端证书续期换了,这里就得跟着改,非常容易漏。
第二个必查项:ssl_verify_depth。默认是 1,意味着只接受"直接由这个 CA 签发"的证书。如果你的客户端证书是通过一层中间 CA 签发的(企业内部 PKI 常见),就要设成 2。设小了不会报"深度不足",只会报一个笼统的验证失败。
后端拿到 $ssl_client_verify 的值通常有三种:SUCCESS、NONE(客户端没给证书)、FAILED:原因。后端必须自己再判一次。因为当 ssl_verify_client optional 时,Nginx 会放行没有证书的连接,只是把这个变量设成 NONE。很多"配了 mTLS 但还是能被匿名访问"的事故,就是这个变量在后端被忽略了。
三、客户端:三件套与"信任自签 CA"这一步
客户端要准备的是:自己的证书、自己的私钥、服务器端 CA 的根证书。用 curl 试最直接:
curl -v --cert client.crt --key client.key \
--cacert server-ca.crt \
https://api.example.com/health如果证书和私钥是分开的两个 PEM 文件,还可以直接合并成一个给 --cert(curl 支持 PEM 里同时含证书与私钥):
cat client.crt client.key > client-bundle.pem
curl -v --cert client-bundle.pem --cacert server-ca.crt https://api.example.com/health--cacert 这一步是自签场景下最常被忘的。如果服务器端证书是自签或者由内部 CA 签发,而客户端用系统默认 CA store 校验,会先在你连上之前就失败:SSL certificate problem: unable to get local issuer certificate。注意这个报错和 mTLS 本身无关,它发生在验证服务器证书阶段。判据是看 -v 输出在哪一步断的:断在 Server certificate 之后、SSL certificate verify 附近,是服务器证书链问题;断在 CertificateRequest 之后,才是客户端证书问题。
其他常见客户端的写法差异,遇到时不必现查:
openssl s_client -connect host:443 -cert c.crt -key c.key -CAfile ca.crt:最底层,能看清全部握手细节。- Node.js:
{ cert, key, ca, rejectUnauthorized: true }。注意ca只影响验证服务器,客户端证书用cert/key;很多库(如 axios)要把 agent 传进去才生效。 - Python requests:
cert=("client.crt", "client.key")。如果服务器 CA 是自签,还要verify="server-ca.crt",两者是不同参数。 - Java:证书要转成 JKS/PKCS12,
-Djavax.net.ssl.keyStore与trustStore分别对应客户端证书和服务器 CA。
四、CDN 与反向代理链:mTLS 最容易失效的一层
如果站点挂在 CDN 后面,或者前面有负载均衡,需要先问清楚一件事:mTLS 终结在哪一层。
大多数 CDN 的做法是:客户端到 CDN 这一段做 mTLS,CDN 到源站这一段用的是它自己的证书或者干脆是 HTTP。这种情况下源站 Nginx 里配的 ssl_verify_client 永远生效不了——它看到的是 CDN 的回源连接,不是终端用户。要在源站"知道"这次请求的客户端证书验证过,只能靠 CDN 透传的头(比如 X-Client-Verify: SUCCESS 加上一段签名)。
反过来的配置错误更常见也更危险:CDN 把回源请求全部放行,源站只信任 X-Forwarded-For。这样任何一个知道源站 IP 的人都能绕过 CDN 直连源站,mTLS 等于不存在。防护方式有两层:源站只允许 CDN 的回源 IP 段访问(在防火墙层做),以及源站自己也校验一个只有 CDN 与源站知道的 X-Auth-Token。
如果前面的负载均衡是 Nginx,它还做了 TLS 终结,那么透传客户端证书信息就要靠自己 set 头,同时要防伪造:X-SSL-Client-Verify 这种头必须由前置代理覆盖而不是追加,否则请求方自己带一个 SUCCESS 就能骗过后端。在 Nginx 里要确认没有用 proxy_pass_request_headers 原样透传,或者用 proxy_set_header X-SSL-Client-Verify $ssl_client_verify; 强制覆盖。
五、把报错和原因对上:六种典型形态
这一节是全文最实用的部分。遇到 mTLS 失败时,先在错误文本里找到对应的一条,能省掉绝大多数盲目排查。
sslv3 alert handshake failure:服务器拒绝了客户端证书,最常见的是证书不是由ssl_client_certificate的 CA 签发,或者客户端压根没给证书。先用openssl s_client确认客户端确实发了证书(看输出里有没有Client Certificate Types和后面的证书内容)。certificate verify failed(客户端侧):两个方向都可能——服务器证书链不全,或者客户端的 CA 文件不对。用openssl verify -CAfile ca.crt server.crt单独验一遍,比反复连服务器快。unable to get local issuer certificate:典型的"中间证书缺失"。服务器只发了叶子证书。修法是把中间证书拼进ssl_certificate指向的文件,顺序是叶子在前、中间在后。tlsv13 alert certificate required:TLS 1.3 下服务器要求证书而客户端没给。这条报错比 TLS 1.2 的handshake failure精确得多,看到它就不用怀疑其他原因了。peer did not return a certificate:客户端收到了CertificateRequest但拿不出匹配的证书。常见于 CA 名单不匹配(服务器的certificate_authorities里没有客户端 CA),或客户端配置里的证书路径写错但被静默忽略。- 连接直接 reset、没有任何 TLS 层报错:说明失败发生在更早的地方——端口不通、防火墙拦截、或者中间设备不支持 SNI。先用
openssl s_client -connect host:443 -servername host手打一遍,看能不能完成 TLS 握手(不计业务层)。
还有一个只在特定实现里出现的形态:unsupported certificate purpose。客户端证书里如果没有 Extended Key Usage: TLS Web Client Authentication(OID 1.3.6.1.5.5.7.3.2),严格的实现会拒绝。自签证书时用 openssl req 生成一定要在配置里写 extendedKeyUsage = clientAuth,否则做出来就是"能签、不能用"。
六、自己造一套测试证书,把问题分离出来
排查时最高效的动作不是盯着生产环境改,而是在本机造一套最小可用的证书对,确认配置逻辑是对的,再回头比对生产环境的差异。
# 1. 造 CA
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout ca.key -out ca.crt -subj "/CN=Test Client CA"
# 2. 客户端证书(带 clientAuth 用途)
openssl req -newkey rsa:2048 -nodes -keyout client.key -out client.csr \
-subj "/CN=test-client"
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 825 -out client.crt \
-extfile <(printf "extendedKeyUsage=clientAuth")
# 3. 验证这一对确实匹配
openssl verify -CAfile ca.crt client.crt
openssl x509 -noout -modulus -in client.crt | md5sum
openssl rsa -noout -modulus -in client.key | md5sum # 两个 md5 必须一致最后那两行是我每次都要跑的:证书和私钥是否配对,只有在 modulus 上比一次才敢确认。生产上出 key values mismatch 的时候,99% 是把某次续期的新证书和旧私钥放在了一起。
造好之后,用 openssl s_server 起一个只验证客户端证书的小服务,直接观察握手:
openssl s_server -accept 8443 -cert server.crt -key server.key \
-CAfile ca.crt -Verify 2 -www-Verify 2 表示要求客户端证书并允许一层中间 CA。成功时服务端会打印 verify return:1,客户端能看到一行 Verify return code: 0 (ok)。这一层过了,再去看 Nginx 的配置,就能确定问题在哪一侧。
七、别把 mTLS 当成万能锁
配置跑通之后,有几个边界值得提前想清楚,避免上线后被动。
第一,客户端证书撤销麻烦。服务器对每个连接都验证证书链,但默认不查撤销状态。证书丢了或者私钥泄露,唯一可靠的办法是换 CA 或者维护一个"已吊销序列号"的名单定期下发。所以客户端证书的私钥一定要单独保管,不要跟代码一起进 git 仓库。
第二,证书过期是静默的。到期那天,所有客户端会同时连不上,而且报错是笼统的握手失败。所以客户端证书的到期时间必须进监控,至少在到期前 30 天有告警。一个简单做法是把所有客户端证书的 notAfter 抓出来,取最小的那个作为监控对象。
第三,mTLS 不解决"这个人能干什么"。它只证明"这个客户端持有某张证书",不能替代后端的权限判断。把授权逻辑寄希望在 mTLS 上是常见的越界——证书被共享的团队里,所有权限就都混在一起了。
第四,性能代价真实存在。每次握手要多做一次签名验证,短连接场景下开销明显。用长连接和 TLS session resumption 能摊薄,但如果业务是"每天调几百次接口"的形态,不如直接看有没有更轻的方案,比如固定 IP 白名单加签名。
八、小结:先分开测,再合起来配
mTLS 的配置量并不大,Nginx 三行、客户端三个文件。真正耗时的是它把所有失败都压缩成"连接被重置"这一种外观。所以整套流程里最有用的习惯是分层验证:先用 openssl s_server 确认证书和 CA 逻辑没问题,再用 openssl s_client 确认网络这一段能握手,最后才回头看 Nginx 和后端的变量透传。
要记住的判据只有一条:从 openssl s_client -v 的输出里,找到握手断在哪一步。断在验证服务器证书之前,是信任链问题;断在 CertificateRequest 之后,才是客户端证书问题;连接根本没建立起来,是网络或端口问题。把这三类分开,剩下的就是查证书的 CN、EKU、有效期这几个固定字段了。