在做基于客户端证书的双向认证(mTLS)时,很多团队都会遇到一个现实问题:某张客户端证书泄露了,或者持有人已经离职,但证书本身还没到期,CA签发时的有效期是一年甚至三年。这种情况下如果Nginx端只校验证书链的有效性,这张"问题证书"依然可以畅通无阻地访问服务。要解决这个问题,就需要引入证书吊销列表CRL(Certificate Revocation List),让Nginx在校验客户端证书时同时检查它是否已被吊销。本文从原理到实操,完整走一遍整个流程。

CRL的工作原理与双向认证的关系
先厘清概念。双向认证指的是服务端除了出示自己的证书给客户端验证,还要求客户端出示证书,由服务端验证客户端身份。在Nginx中,这通过ssl_verify_client on开启。开启之后,Nginx会做几层校验:证书是否由受信任的CA签发、证书链是否完整、证书是否在有效期内,以及这张证书是否出现在吊销列表中。
CRL本质上是一份由CA定期签署发布的"黑名单"文件,里面记录了已被吊销的证书序列号。因为CRL是CA用私钥签名的,所以不能被伪造篡改。Nginx收到客户端证书后,会把证书中的序列号和CRL里的记录比对,一旦命中就拒绝连接。Nginx使用的是OpenSSL的校验逻辑,对应的指令是ssl_crl,它指向一个PEM格式的CRL文件。
需要注意一点:CRL文件本身也有有效期(nextUpdate字段)。如果CA生成CRL时设定的更新周期过了,Nginx校验会直接报错"CRL has expired",导致所有客户端证书校验失败,这一点在后面排查小节会详细说。
用OpenSSL搭建测试CA并签发客户端证书
为了演示完整流程,先用OpenSSL自建一个CA。创建必要的目录结构和索引文件:
mkdir -p ./demoCA/{certs,crl,newcerts,private}
touch ./demoCA/index.txt
echo 1000 > ./demoCA/serial
# 生成CA私钥和自签证书
openssl genrsa -out ca.key 2048
openssl req -new -x509 -days 3650 -key ca.key -out ca.crt \
-subj "/C=CN/ST=BJ/O=Demo/CN=DemoCA"
接着签发一张客户端证书,模拟正常用户的身份:
openssl genrsa -out client1.key 2048 openssl req -new -key client1.key -out client1.csr \ -subj "/C=CN/ST=BJ/O=Demo/CN=client1" openssl ca -in client1.csr -out client1.crt -cert ca.crt -keyfile ca.key \ -config /etc/ssl/openssl.cnf -days 365
用openssl ca方式签发的好处是,CA数据库(index.txt)会自动记录证书状态,后面吊销时直接依赖这个数据库。如果图省事用openssl x509 -req签发,吊销环节就会缺少数据库记录,操作会麻烦很多,这一点新手经常踩坑。
执行证书吊销并生成CRL文件
假设client1这张证书需要作废,执行吊销命令:
openssl ca -revoke client1.crt -cert ca.crt -keyfile ca.key \ -config /etc/ssl/openssl.cnf
执行成功后,index.txt中该证书对应行的状态会从V(有效)变成R(已吊销)。然后生成CRL文件,注意-crldays参数决定了CRL的有效期:
openssl ca -gencrl -out ca.crl -cert ca.crt -keyfile ca.key \ -config /etc/ssl/openssl.cnf -crldays 30
生成的ca.crl是PEM格式的文本文件,Nginx的ssl_crl指令要求PEM格式,所以可以直接使用。可以用下面的命令查看CRL内容确认吊销记录:
openssl crl -in ca.crl -noout -text
输出中会看到Serial Number字段列出了被吊销证书的序列号,还有Next Update时间。这里有个关键决策:crldays设多长?设得太短,比如1天,意味着你必须每天重新生成并更新CRL文件,运维压力大;设得太长,比如365天,证书泄露后到CRL刷新之间的暴露窗口并没有缩短,但好处是容错高。一般生产环境建议7到30天,再配合自动化脚本定期重新生成CRL并reload Nginx。
Nginx配置ssl_crl完成吊销校验
下面是完整的Nginx server配置,同时开启了双向认证和CRL校验:
server {
listen 443 ssl;
server_name mtls.demo.ipipp.com;
# 服务端证书
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
# 要求客户端出示证书,并指定信任的CA
ssl_client_certificate /etc/nginx/ssl/ca.crt;
ssl_verify_client on;
ssl_verify_depth 2;
# 关键指令:加载CRL吊销列表
ssl_crl /etc/nginx/ssl/ca.crl;
location / {
# $ssl_client_verify值为SUCCESS/FAILED/NONE
if ($ssl_client_verify != SUCCESS) {
return 403;
}
proxy_pass http://127.0.0.1:8080;
}
}
几个要点值得展开。第一,ssl_crl指定的文件必须是PEM格式,如果拿到的是DER格式的CRL(二进制),需要转换:openssl crl -in ca.der -inform DER -out ca.pem。第二,配置了ssl_crl之后,只要CRL文件存在,所有证书都会被检查吊销状态,包括尚未被吊销的正常证书,这属于全量校验,不是只针对黑名单里的证书。
第三,可以利用$ssl_client_verify这个内置变量做更细的控制,比如对未携带证书的请求返回自定义页面而不是默认的400错误:
error_page 495 = @no_cert;
error_page 496 = @invalid_cert;
location @no_cert {
return 403 '{"code":40301,"msg":"client certificate required"}';
}
location @invalid_cert {
return 403 '{"code":40302,"msg":"client certificate invalid or revoked"}';
}
其中495是Nginx内置的"证书校验失败"状态码,496是"未提供证书"。被CRL命中的证书会触发495,通过这种方式能明确区分错误类型,方便客户端排查问题。
常见问题排查与生产实践建议
最经典的报错是握手阶段直接失败,Nginx的error日志里出现"certificate revoked"字样,这其实说明CRL校验生效了,属于预期行为。但如果日志里出现"CRL has expired",说明CRL文件过了Next Update时间,此时OpenSSL会认为整个吊销列表不可信,所有客户端证书校验都会失败。解决办法是重新生成CRL并reload Nginx。生产上强烈建议用crontab加一个自动任务:
#!/bin/bash # 每周重新生成CRL并平滑重载Nginx openssl ca -gencrl -out /etc/nginx/ssl/ca.crl.new \ -cert /path/ca.crt -keyfile /path/ca.key \ -config /etc/ssl/openssl.cnf -crldays 30 mv /etc/nginx/ssl/ca.crl.new /etc/nginx/ssl/ca.crl nginx -t && nginx -s reload
注意生成到临时文件再mv替换,避免Nginx读取到写了一半的文件。另外要提醒的是,nginx -s reload并不会中断现有连接,新配置对新连接生效,所以更新CRL不会影响线上正在进行的请求。
还有一点容易被忽略:如果你的CA层级比较深,比如根CA下面有中间CA,客户端证书由中间CA签发,那么ssl_crl文件中需要包含整条链上所有CA的CRL,把多个CRL的PEM内容直接拼接在一个文件里即可,OpenSSL会按签发者自动匹配。同时ssl_verify_depth要设置得足够大,覆盖完整的证书链深度,否则校验会在链的中间环节就中断。
最后对比一下CRL和OCSP。CRL是定期下载的静态名单,实现简单、不依赖额外网络交互,适合客户端规模可控的内部系统;OCSP是实时查询,及时性更好,但需要维护响应服务。对于Nginx的双向认证场景,客户端证书数量通常有限,CRL方案部署成本最低、稳定性最好,是大多数内部mTLS体系的首选。如果将来规模扩大或者对吊销实时性要求极高,再考虑迁移到OCSP或短周期证书方案也不迟。