nginx 如何配置 mTLS 客户端证书
使用 openssl 创建私有 CA,为用户签发客户端证书,并配置 nginx 拒绝缺少证书或未受信任证书的请求,保护管理面板和指标端点。
mTLS 的作用
双向 TLS(通常写作 mTLS)会让 nginx 要求每个客户端提供证书。如果证书缺失,或不是由您控制的证书颁发机构(CA)签发,nginx 就会拒绝请求。检查在 TLS(传输层安全)握手期间进行。因此,没有有效客户端证书的调用方根本无法到达您的应用。其优势在于:管理面板或指标端点可以直接暴露在公网,无需登录页面,也没有可供机器人猜测的内容。
配置规模很小。使用 openssl 创建一个私有 CA,为每个人签发一张证书,然后在 nginx server block 中添加三条指令。决定这套配置能否稳定运行一年的工作属于运维范畴。因此,本指南的大部分内容会介绍证书有效期、证书吊销、按人员签发证书,以及客户端被拒绝且无人能判断原因时的处理方法。
两条链,而不是一条
mTLS 配置中有两条证书链,二者互不相关。将它们混为一谈,是几乎所有人都会犯的第一个错误。
第一条链属于服务器。您的 VPS 会为 admin.example.com 提供一张由 Let's Encrypt 等公共 CA 签发的证书,浏览器会根据操作系统自带的根证书存储验证该证书。mTLS 不会改变这一部分。如果 certbot 今天为您签发了这张证书,请保持原样:参见 使用 certbot 为 nginx 签发 Let's Encrypt 证书。
第二条链属于客户端。您创建一个自己的小型 CA,为每个需要访问的用户签发一张证书,然后告知 nginx 在验证客户端时信任该 CA,且只信任该 CA。公共根证书存储不会识别您的 CA,也不需要识别。唯一需要信任它的一方是 nginx,具体通过 ssl_client_certificate 文件实现。
因此,ssl_client_certificate 永远不会影响 nginx 提供的证书,Let's Encrypt 证书链也不会影响哪些客户端可以访问。将 ssl_client_certificate 指向 fullchain.pem 并不会产生表面上看起来的效果:该指令指定客户端证书可以来自哪些签发者,而客户端位于连接的另一端。让服务器自身在执行出站操作时信任您的 CA,是另一项独立工作,详见将您自己的 CA 添加到 Ubuntu 信任存储;nginx 在验证客户端时读取的不是系统信任存储。
使用 openssl 构建自己的客户端 CA
请在 Web 服务器之外的位置构建 CA。nginx 只需要 CA 的公有证书。CA 私钥用于签署新的客户端证书,因此如果将它留在面向互联网的主机上,一旦主机被入侵,攻击者就可以随意为自己签发有效的客户端证书。
mkdir -p ~/client-ca/certs ~/client-ca/newcerts ~/client-ca/private ~/client-ca/csr
cd ~/client-ca
chmod 700 private
touch index.txt
echo 1000 > serial
echo 1000 > crlnumberindex.txt、serial 和 crlnumber 构成 CA 数据库。没有它们,openssl ca 将无法运行。它们也使以后能够执行吊销操作,因为吊销列表记录的是序列号。因此,CA 必须记住每个序列号签发给了谁。
创建 ~/client-ca/openssl.cnf。将 dir 设置为该目录的实际路径,因为 openssl ca 不会展开 ~。
[ ca ]
default_ca = client_ca
[ client_ca ]
dir = /home/you/client-ca
database = $dir/index.txt
new_certs_dir = $dir/newcerts
certificate = $dir/ca.crt
private_key = $dir/private/ca.key
serial = $dir/serial
crlnumber = $dir/crlnumber
default_md = sha256
default_days = 365
default_crl_days = 30
policy = policy_loose
rand_serial = no
unique_subject = no
email_in_dn = no
[ policy_loose ]
commonName = supplied
countryName = optional
stateOrProvinceName = optional
organizationName = optional
organizationalUnitName = optional
emailAddress = optional
[ client_ext ]
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer现在创建 CA 密钥及其自签名证书:
openssl genrsa -aes256 -out private/ca.key 4096
chmod 600 private/ca.key
openssl req -x509 -new -key private/ca.key -sha256 -days 3650 \
-subj "/O=Example Ops/CN=Example Ops Client CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-out ca.crt-aes256 会为 CA 密钥设置口令,因此每次执行签名操作时都会要求输入口令。这正是它的作用。检查创建的内容:
openssl x509 -in ca.crt -noout -subject -dates -ext basicConstraints主题应为您的 CA,有效期应为十年。扩展行应显示为 CA:TRUE, pathlen:0。pathlen:0 表示此 CA 可以签署终端实体证书,但不能签署另一个 CA,从而使证书链严格保持一层,并且可以不修改 ssl_verify_depth。
为每个人签发客户端证书
每个人使用一个证书。团队不得共用一个证书,因为撤销共享证书会导致所有人无法访问,而且无法据此确定请求发起人。
openssl genrsa -out private/alice.key 2048
openssl req -new -key private/alice.key -out csr/alice.csr \
-subj "/O=Example Ops/CN=alice"
openssl ca -config openssl.cnf -extensions client_ext \
-days 365 -notext -in csr/alice.csr -out certs/alice.crtopenssl ca会打印即将签发的证书,要求输入 CA 密码短语,并要求确认两次,然后向 index.txt 追加一行。编写脚本时,加入 -batch。client_ext 部分很重要,因为其中有一行:extendedKeyUsage = clientAuth。如果证书中的扩展密钥用法只列出 serverAuth,则会被拒绝用于客户端身份验证。因此应明确指定用途,不要依赖默认行为。
交付前,先使用 CA 验证证书和密钥是否匹配:
openssl verify -CAfile ca.crt certs/alice.crt该命令会打印 certs/alice.crt: OK。任何其他输出都表示证书与 CA 不匹配,任何 nginx 配置都无法修复这一问题。
将密钥和证书打包到浏览器可以导入的单个文件中:
openssl pkcs12 -export -inkey private/alice.key -in certs/alice.crt \
-name "alice at example ops" -out alice.p12导出时会要求设置密码,用于保护传输中的文件。通过不同渠道分别发送文件和密码,并向用户提供 .p12,而不是单独提供 .key。可以添加 -certfile ca.crt,将 CA 包含在捆绑包中,但 nginx 不需要它:nginx 已经保存了 ca.crt,因此由该 CA 直接签名的证书可以自行完成验证。
Ubuntu 24.04 随附的 OpenSSL 3 会使用当前的加密算法写入 PKCS#12 文件。截至 2026 年 8 月,正在使用的浏览器和操作系统都可以读取这类文件。如果旧版导入程序拒绝该文件,请添加 -legacy 重新导出。该选项会回退到导入程序所需的旧版算法。使用该选项前,先阅读导入程序显示的消息。
配置 nginx 的 ssl_client_certificate 和 ssl_verify_client
将 CA 证书复制到服务器。只能复制 CA 证书。
scp ca.crt user@admin.example.com:/tmp/client-ca.crt
ssh user@admin.example.com \
'sudo install -o root -g root -m 644 /tmp/client-ca.crt /etc/nginx/client-ca.crt'这里使用 644 权限正确。CA 证书属于公开信息。CA 密钥应保留在您的工作站上。
然后,在已经负责 TLS 终止的 server 块中添加以下 3 条指令:
server {
listen 443 ssl;
server_name admin.example.com;
ssl_certificate /etc/letsencrypt/live/admin.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/admin.example.com/privkey.pem;
ssl_client_certificate /etc/nginx/client-ca.crt;
ssl_verify_client on;
ssl_verify_depth 1;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}ssl_verify_depth 1 是 nginx 的默认值,表示客户端证书必须直接由该文件中的 CA 签名。只有在添加中间 CA 时,才应提高该值。nginx 还会在握手期间将 ssl_client_certificate 中的主题名称发送给客户端,浏览器因此知道应提供哪些证书。这也是应使用 ssl_client_certificate 而不是 ssl_trusted_certificate 的原因。两者的验证方式相同,但 ssl_trusted_certificate 不会发送证书列表。
Ubuntu 24.04 附带 nginx 1.24,此版本将 HTTP/2 配置在 listen 行中,写作 listen 443 ssl http2;。在 nginx 1.25.1 及更高版本中,该写法已弃用,HTTP/2 应使用独立指令 http2 on;。两种写法都不会改变证书检查。
重新加载配置并查看结果:
sudo nginx -t && sudo systemctl reload nginx
curl -i https://admin.example.com/nginx -t 会输出 syntax is ok 和 test is successful。curl 请求未携带证书,因此应返回 400 Bad Request,响应正文为 No required SSL certificate was sent。这表示 nginx 在自身的入口处拒绝了请求,说明配置已生效,应用从未收到请求。现在使用正确的方式再次测试:
curl --cert certs/alice.crt --key private/alice.key https://admin.example.com/此时应返回您的应用提供的内容。
为何应在 server 块中设置访问控制
证书会在 TLS 握手期间交换,此时 nginx 还没有读取请求行,因此不知道请求将匹配哪个 location。将 ssl_verify_client on; 放在 location 中,会要求客户端在连接过程中重新协商。TLS 1.3 已移除重新协商,HTTP/2 也禁止该操作,因此在当前技术栈中,这种配置会直接失败,而不会提示客户端提供证书。
请自行划分作用域。在 server 级别请求证书,然后按 location 决定处理方式:
ssl_verify_client optional;
location /metrics {
if ($ssl_client_verify != SUCCESS) { return 403; }
proxy_pass http://127.0.0.1:9090;
}
location /healthz {
proxy_pass http://127.0.0.1:8080;
}$ssl_client_verify 表示 SUCCESS;如果客户端未发送证书,则表示 NONE;如果发送了证书但验证失败,则表示 FAILED:,后面跟随失败原因。使用 optional 时,nginx 会请求证书,但只有在客户端发送证书时才进行验证。这样,上面的公共 /healthz 路径可以正常工作,而 /metrics 仍保持关闭。客户端发送但验证失败的证书,nginx 仍会在此处拒绝。如果您希望自行检查验证失败的证书,则应使用 optional_no_ca;此时,您自己的测试必须将除 SUCCESS 之外的所有值都视为拒绝。
nginx 为此使用了非标准状态码,error_page 可以捕获这些状态码,使被拒绝的访问者获得解释,而不是只看到一个 400:
error_page 495 496 = @needcert;
location @needcert {
default_type text/plain;
return 200 "This host requires a client certificate. Ask ops for one.\n";
}495 表示客户端证书验证失败。496 表示客户端未提供证书。此页面应使用纯文本,因为阅读它的用户没有会话,也没有账户。
如何在浏览器中安装客户端证书?
Firefox 使用自己的证书存储区:依次打开“设置”、“隐私与安全”、“查看证书”、“您的证书”选项卡和“导入”,然后选择 .p12 并输入其密码。
Chrome 和 Edge 在 Windows 和 macOS 上使用操作系统证书存储区,因此打开 .p12 文件后会启动系统导入向导。在 Linux 上,Chrome 会读取主目录中的独立 NSS(网络安全服务)数据库,使用命令行工具导入更可靠:
sudo apt install -y libnss3-tools
pk12util -d sql:$HOME/.pki/nssdb -i alice.p12随后加载该网站,浏览器会询问要发送哪个证书。Chrome 会在本次浏览器会话期间记住该选择;如果希望再次看到提示,请重启浏览器。证书只存在于一台计算机上的一个浏览器配置文件中。因此,导入 Firefox 的证书对 Chrome 不可见,二者对手机也都不可见。
使用 curl --cert 测试
使用 curl 进行调试,因为它会报告执行的操作。
curl -v --cert certs/alice.crt --key private/alice.key https://admin.example.com/您可以将证书和密钥合并到一个 PEM 文件中,然后将其作为 --cert alice.pem 传入。如果密钥设置了密码短语,curl 会提示您输入。curl 也接受 --cert alice.pem:passphrase,但密码会保留在 shell 历史记录中,因此应使用提示输入的方式。
在将问题归咎于 nginx 之前,建议先执行以下两项检查。首先,证书和密钥必须匹配:
openssl x509 -noout -pubkey -in certs/alice.crt | openssl sha256
openssl pkey -pubout -in private/alice.key | openssl sha256如果两个哈希值相同,说明这两个文件属于同一对。如果哈希值不同,说明您混用了不同人员的文件,客户端不会为您指出这个原因。
其次,服务器应请求您的 CA:
openssl s_client -connect admin.example.com:443 -servername admin.example.com </dev/null在输出中查找 Acceptable client certificate CA names 块,并检查其中是否包含您的 CA subject。如果整个块都不存在,说明 nginx 没有在处理该请求的 server block 上请求证书。因此,您的指令被写入了另一个 server block,通常是默认 server。
将客户端 CN 传递给应用
证书说明了调用方的身份,但代理后的应用无法看到 TLS 层,因此 nginx 必须将该名称传递给应用。
map $ssl_client_s_dn $client_cn {
default "";
"~,?CN=(?<cn>[^,]+)" $cn;
}$ssl_client_s_dn 以 RFC 2253 格式保存主题可分辨名称,其形式类似 CN=alice,O=Example Ops。该映射会将 CN 字段提取到 $client_cn 中。请将 CN 保持为普通用户名,因为该格式会转义 CN 中的逗号,而上面的简单正则表达式无法处理这种转义。
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Client-Cert-CN $client_cn;
proxy_set_header X-Client-Cert-Serial $ssl_client_serial;
}proxy_set_header 会替换调用方发送的同名请求头,因此调用方无法通过此位置伪造 X-Client-Cert-CN。以下两个条件可以保证这一点。只有当内层级别未定义自己的请求头时,nginx 才会从外层继承 proxy_set_header;因此,另一个包含 proxy_set_header 行的位置会静默丢弃上层设置的所有请求头,包括此请求头。应用还必须只能通过 nginx 访问。这意味着应将应用绑定到 127.0.0.1,而不是 0.0.0.0,因为监听公网端口的应用会直接读取来自互联网的伪造请求头。逐行讲解 nginx 反向代理配置介绍了代理端的配置。如果应用需要完整证书而不是名称,$ssl_client_escaped_cert 会以 URL 编码形式传递证书,并确保其可安全放入请求头中。
如何撤销一个客户端证书?
有人离职,或者笔记本电脑丢失。您只需撤销对应的证书,其他人仍可继续工作。这正是为每个人单独签发证书的原因。
cd ~/client-ca
openssl ca -config openssl.cnf -revoke certs/alice.crt
openssl ca -config openssl.cnf -gencrl -out crl.pem第一个命令会将 index.txt 中对应序列号的状态从 V 改为 R。第二个命令会生成证书吊销列表(CRL),即一个列出已撤销序列号的签名文件。将该文件部署到服务器,并在 nginx 中通过 ssl_crl /etc/nginx/client-ca.crl; 与其他指令一起指定它。
scp crl.pem user@admin.example.com:/tmp/client-ca.crl
ssh user@admin.example.com 'sudo install -m 644 /tmp/client-ca.crl /etc/nginx/client-ca.crl && sudo nginx -t && sudo systemctl reload nginx'这里存在一个会让所有客户端都无法通过验证的陷阱。CRL 包含一个由 default_crl_days 设置的 nextUpdate 日期;上面的配置将其设置为 30。超过该日期后,OpenSSL 会将 CRL 视为过期,并对所有客户端证书使用 CRL has expired 验证失败,而不只是拒绝已撤销的证书。nginx 在加载配置时读取该文件,因此磁盘上的新 CRL 在重新加载配置前不会生效。应在有效期内留出充足余量,按计划重新生成并重新加载 CRL,例如每周执行一次,而有效期设置为 30 天。复制文件前,请先检查日期:
openssl crl -in crl.pem -noout -lastupdate -nextupdate如果用户数量不多,还有一种更简单的方案。CA 由您管理,因此 nginx 可以直接拒绝某个序列号,跳过 CRL 机制:
map $ssl_client_serial $revoked {
default 0;
"1002" 1;
}在 location 中配合使用 if ($revoked) { return 403; }。这种方式不需要记住过期日期,但规则不会同步到其他系统,因此任何其他信任该 CA 的服务都不知道这条拒绝规则。对于一台 nginx 代理一个应用的场景,这是简单且合理的选择。当入口不止一个时,再改用 CRL。
客户端证书应设置多长的有效期?
将客户端证书的有效期设为一年。如果可以承受重新签发的工作量,也可以设得更短。这里最隐蔽的问题是证书过期,因为系统不会提前提醒持有者。某天早上,用户打开面板时,nginx 拒绝连接,而浏览器会用自己的措辞描述该拒绝,通常不会明确提到证书已过期。将 CA 的有效期设为十年,并把到期日期记录在您确实会查看的位置。因为 CA 证书一旦过期,其签发的所有证书都会在同一天停止验证。
以下两个命令可以帮助您提前发现问题:
openssl x509 -in certs/alice.crt -noout -subject -serial -enddate
awk -F'\t' '{print $1, $2, $4}' ~/client-ca/index.txtindex.txt 的第一列是状态:V 表示有效,R 表示已撤销,E 表示已过期。第二列是 YYMMDDHHMMSSZ 格式的到期时间,第四列是序列号。该文件是记录证书持有者及其证书的唯一依据,因此应与 CA 密钥一起备份,并将二者都作为机密信息处理。
续期意味着签发新证书,而不是延长原证书的有效期。生成新的密钥和 CSR(证书签名请求),签发证书并交付给用户。用户确认新证书可以正常使用后,再撤销旧证书。
mTLS 可以防范什么,以及不能防范什么
它消除的是未经身份验证的访问。扫描器发现您的主机名后,会在握手阶段被拒绝,因此不会发送 HTTP 请求,不会看到登录表单,也无法使用被盗密码尝试登录。凭据填充攻击没有可填充的入口。没有证书的攻击者无法访问应用的登录流程漏洞。它还消除了人们粘贴到聊天中的共享密钥,因为私钥是一个不容易被意外复制的文件。
它无法防范的是客户端已被入侵的情况。笔记本电脑上的恶意软件可以获取密钥文件,用户输入口令后也能立即获取该口令。对服务器而言,这类攻击者与合法用户完全相同,因为证书只能证明持有某个文件,不能证明操作方确实是用户本人。.p12密码和全盘加密仍然很重要。
它也不是授权机制。除非检查 $client_cn 并根据其值采取措施,否则每个有效证书都可以访问该 server block 提供的所有内容。默认情况下,两个证书持有者拥有完全相同的访问权限。
它只保护经过 nginx 的路径。如果应用还监听公网端口,那么前置的 mTLS 只是装饰:将应用绑定到 127.0.0.1,并在防火墙中关闭该端口。进入同一台服务器的另一扇门是 SSH,也应受到同样的重视,详见 加固 VPS 上的 SSH 访问。
最后还有一个限制,而且您启用它的当天就会遇到。任何无法提供证书的客户端都会停止工作:运行状态监控器、支付服务提供商发送的 webhook、RSS 阅读器,以及没有可访问证书存储的移动应用。设置 ssl_verify_client on 之前先决定如何处理这些客户端,因为故障会完全阻断,而且对方通常不会显示任何错误。
客户端被拒绝时,查看客户端报告的内容
被拒绝的客户端显示的消息取决于浏览器、curl 版本以及底层 TLS 库。因此,应查看您实际使用的客户端输出的内容,而不要与其他地方记录的消息逐字比对。真正有用的细节在服务器端。
sudo tail -n 50 /var/log/nginx/error.log被拒绝的证书会生成一行包含 client SSL certificate verify error 的日志,后面是 OpenSSL 给出的原因。应根据这个原因处理问题。通常只有几种情况。证书来自与 ssl_client_certificate 中指定文件不同的 CA。证书已超出有效期。服务器上的 CRL 已超过其 nextUpdate,因此现在会拒绝所有客户端,而不只是其中一个。
如果浏览器完全没有发送证书,问题发生在验证之前。nginx 会在握手期间发送可接受的颁发者名称。浏览器在其证书存储区中找不到匹配项,因此没有可发送的证书。请将 .p12 再次导入到您实际用于浏览的配置文件中。
还有一种情况需要说明。如果您使用单独的自签名客户端证书进行测试,而不是使用 CA 签发的证书,验证将无法通过。因为 nginx 会根据 CA 文件检查签名,而自签名证书不在该文件中。证书的生成方式与在 Ubuntu 上生成自签名证书相同。mTLS 只需要额外执行一步,即由您的 CA 为其签名。
FAQ
如果使用 mTLS,还需要 Let's Encrypt 证书吗?
需要。这两个证书互不相关。服务器会提供自己的证书,让浏览器信任该主机名;该证书仍必须由浏览器已知的 CA 签发。客户端 CA 是另一条独立的私有证书链,仅用于验证连接方的身份。设置 ssl_client_certificate 不会改变 nginx 提供的证书,并且该配置不能指向 Let's Encrypt 证书链。
为什么浏览器从不提示我选择证书?
nginx 会在握手期间发送可接受签发者的列表,该列表根据 ssl_client_certificate 中的文件生成。浏览器只会提供签发者出现在该列表中的证书。因此,不出现提示表示浏览器中没有来自你的 CA 的证书:证书可能被导入了其他浏览器配置文件,或者证书由不同于服务器上安装的 CA 签发。运行 openssl s_client -connect admin.example.com:443,并在输出中查找可接受的客户端证书 CA 名称,以确认服务器实际请求的是哪个 CA。
可以只在一个 URL 上要求客户端证书吗?
不能在 location 中使用 ssl_verify_client on 来实现。证书会在握手期间交换,此时 nginx 还不知道请求路径;而且可用于绕过这一限制的重新协商机制已从 TLS 1.3 中移除,并且在 HTTP/2 中被禁止。在 server 块中设置 ssl_verify_client optional;,然后在每个受保护的 location 中检查 $ssl_client_verify;如果不是 SUCCESS,则返回 403。
如何撤销某一个人的访问权限?
使用 openssl ca -revoke 撤销该证书,使用 openssl ca -gencrl 重新生成列表,将其复制到服务器,然后重新加载 nginx,使其读取新文件。其他人不会受到影响,但前提是每个人都持有自己的证书,而不是共用同一张证书。注意 CRL 的 nextUpdate 日期,因为 CRL 过期后,所有客户端的验证都会失败,而不仅是被撤销证书的客户端。
mTLS 会取代登录页面吗?
就访问控制而言,会:没有证书时,请求根本无法到达应用,因此没有可攻击的表单,也没有可猜测的密码。就应用内部的身份识别而言,不会。证书只能证明调用方持有密钥文件,因此被盗的笔记本电脑仍可代表有效用户。将 CN 传递给上游,保留应用已有的账户和权限,并将证书视为这些账户和权限前面的访问门槛。