第 5 篇:Nginx HTTPS、证书与安全入口——从 TLS 握手、自动续期到安全边界
专栏:《Nginx 从入口代理到云原生流量治理》
文章序号:第 5 篇
Nginx 实验版本:Nginx Open Source 1.30.3(稳定版)
当前主线版本:1.31.2
后端实验版本:Spring Boot 4.1.0、Java 21
实验环境:Docker Compose、Linux、OpenSSL、curl
更新时间:2026-07-07
本文在专栏中的位置
前四篇已经完成了入口代理的主体链路:
- 第一篇确定了 Nginx 在后端架构中的位置;
- 第二篇讲清了配置层级以及
server、location匹配; - 第三篇拆解了反向代理、Header、超时、缓冲、WebSocket 和 SSE;
- 第四篇将单实例代理扩展为多实例负载均衡与高可用。
到这里,系统已经能够通过 Nginx 接收请求、选择后端并完成故障转移,但如果入口仍然暴露明文 HTTP,客户端和 Nginx 之间的数据就可能被窃听、篡改或冒充。即使内部服务本身具备登录、JWT 和权限校验,攻击者仍可能在请求到达应用之前截获 Cookie、Token、请求体或响应内容。
因此,第五篇要解决的问题不是“如何加一行 listen 443 ssl”,而是构建一个完整的安全入口:
- 客户端如何确认自己连接的确实是目标服务;
- TLS 1.2/1.3 如何协商密钥并保护后续 HTTP 数据;
- Nginx 如何根据 SNI 选择证书;
- 证书链、私钥和中间证书分别承担什么职责;
- HTTP 应该如何跳转到 HTTPS;
- HSTS 为什么不能随意开启
preload; - 证书如何通过 ACME 自动签发和续期;
- TLS 应该在 CDN、云负载均衡器、Nginx 还是应用层终止;
- 安全 Header、CORS、Host 校验和请求限制应该由谁负责;
- HTTPS 出现证书错误、握手失败或重定向循环时如何分层排查。
需要先明确一个核心结论:
HTTPS 只解决传输加密、完整性和服务端身份验证,不自动解决用户认证、业务授权、注入攻击、越权、CSRF、SSRF 或数据泄露。
Nginx 可以成为安全入口,但它不能替代应用安全。
独立插图 01:HTTPS 安全入口架构
对应文件:01-https-entry-architecture.png
下一篇将在安全入口基础上进入缓存、压缩与静态资源优化。
学习目标
学完本文后,你应当能够:
- 解释 HTTP 明文传输面临的窃听、篡改和身份冒充风险;
- 区分 HTTPS、TLS、证书、CA、公钥、私钥和会话密钥;
- 从
ClientHello到Finished解释一次 TLS 1.3 完整握手; - 说明 ECDHE、证书签名和对称加密分别解决什么问题;
- 理解 SNI 与 ALPN 在 Nginx HTTPS 虚拟主机中的作用;
- 判断
fullchain.pem、叶子证书、中间证书和根证书应该如何部署; - 检查证书与私钥是否匹配、SAN 是否覆盖目标域名;
- 使用
listen 443 ssl、http2 on、ssl_certificate和ssl_certificate_key配置 HTTPS; - 说明 TLS 1.2 密码套件与 TLS 1.3 密码套件的配置边界;
- 解释 Session Cache、Session Ticket、会话恢复与 0-RTT 风险;
- 正确选择 301、302、307 和 308 跳转,并理解方法是否保留;
- 说明 HSTS 只在 HTTPS 响应中生效,以及
includeSubDomains和preload的不可逆风险; - 区分 ACME HTTP-01、DNS-01 和 TLS-ALPN-01;
- 使用 Certbot 或可选的 Nginx ACME 模块设计证书自动续期流程;
- 解释为什么续期成功后还需要
nginx -t、reload、到期监控和回滚; - 比较 CDN、云 LB、Nginx 和应用层 TLS 终止;
- 判断哪些安全 Header 适合入口统一添加,哪些必须由应用动态生成;
- 解释 CORS 为什么不是鉴权,并正确处理预检、凭证与
Vary: Origin; - 使用
openssl s_client、curl -v、nginx -T定位 HTTPS 故障; - 完成一个 Docker Compose + Spring Boot + 本地 CA 证书的可运行实验。
一、HTTP 为什么不安全,TLS 到底解决什么
1.1 明文 HTTP 的三个核心风险
客户端直接通过 HTTP 请求后端时,请求行、Header、Cookie、查询参数和请求体默认以明文在网络上传输。网络路径中的恶意节点可能实施三类攻击。
风险一:窃听
攻击者可以读取:
- 登录 Cookie;
- Bearer Token;
- 用户名和密码;
- 订单、地址和支付相关数据;
- API 请求与响应;
- URL 中的敏感查询参数。
即使密码在数据库中经过哈希处理,也无法阻止它在传输过程中被截获。
风险二:篡改
攻击者不仅能读取数据,还可能修改:
- 将下载文件替换为恶意程序;
- 修改 API 请求参数;
- 注入脚本;
- 替换响应中的跳转地址;
- 将用户导向仿冒登录页面。
风险三:身份冒充
客户端无法仅凭一个 IP 地址确认对方是否真的是 api.example.com。DNS 污染、ARP 欺骗、恶意代理或错误路由都可能把请求导向攻击者控制的服务器。
1.2 TLS 提供的安全属性
TLS 主要提供:
| 能力 | 解决的问题 | 依赖机制 |
|---|---|---|
| 机密性 | 第三方不能直接读取通信内容 | 对称加密 |
| 完整性 | 数据被修改后能够被检测 | AEAD 认证加密 |
| 服务端身份验证 | 客户端确认服务端证书属于目标域名 | CA 证书链与数字签名 |
| 可选客户端身份验证 | 服务端验证客户端证书 | mTLS |
| 前向保密 | 长期私钥未来泄露时,历史会话不应被直接解密 | 临时 ECDHE 密钥交换 |
HTTPS 可以理解为:
HTTP 语义 ↓TLS 加密通道 ↓TCP(HTTP/1.1、HTTP/2)或 QUIC/UDP(HTTP/3)对于常见 Nginx HTTPS:
客户端 === TLS ===> Nginx -- HTTP 或 HTTPS --> Spring Boot客户端与 Nginx 的 TLS 连接和 Nginx 与后端的连接是两条独立连接。入口启用 HTTPS,不代表 Nginx 到后端也自动加密。
1.3 TLS 不解决什么
TLS 无法自动解决:
- 用户密码过弱;
- JWT 被应用日志泄露;
- SQL 注入;
- XSS;
- CSRF;
- 越权访问;
- 服务端主动返回敏感数据;
- 私钥在服务器磁盘上被窃取;
- 已登录用户执行恶意操作;
- Nginx 之后的内部链路被攻击。
因此不能把“网站已经是 HTTPS”当作系统已经安全。
二、TLS 1.3 握手:从非对称认证到对称加密
2.1 为什么不能一直使用非对称加密
非对称密码适合:
- 身份认证;
- 数字签名;
- 密钥协商。
但它不适合直接加密大量业务数据,因为计算成本和数据长度限制都明显高于对称加密。
TLS 的基本思路是:
- 使用证书和数字签名确认服务端身份;
- 使用临时密钥交换协商共享秘密;
- 通过密钥派生函数生成会话密钥;
- 使用高效的对称 AEAD 算法保护 HTTP 数据。
2.2 TLS 1.3 完整握手主流程
独立插图 02:TLS 1.3 完整握手时序
对应文件:02-tls13-handshake-sequence.png
典型流程如下。
第一步:客户端发送 ClientHello
客户端发送:
- 支持的 TLS 版本;
- 支持的密码套件;
- 随机数;
- 临时密钥份额;
- SNI;
- ALPN;
- 支持的签名算法;
- 可能的会话恢复信息。
SNI 告诉 Nginx 客户端希望访问哪个域名;ALPN 用于协商 h2、http/1.1 等上层协议。
第二步:服务端发送 ServerHello
Nginx 选择:
- TLS 版本;
- 密码套件;
- 服务器临时密钥份额。
双方通过临时 ECDHE 参数得到相同共享秘密,但共享秘密本身不会直接在网络上传输。
常见安全组包括 X25519、secp256r1 等。实际选择由客户端、Nginx、OpenSSL 版本和配置共同决定。
第三步:服务端发送加密扩展
在 TLS 1.3 中,后续握手消息更早进入加密状态。服务端会确认 ALPN 等扩展,例如选择 HTTP/2 的 h2。
第四步:服务端发送证书链
证书包含:
- 域名身份;
- 服务端公钥;
- 有效期;
- 签发者;
- 用途约束;
- CA 的数字签名。
第五步:CertificateVerify
Nginx 使用与证书公钥对应的私钥对握手上下文签名。
客户端使用证书中的公钥验证签名,从而确认:
- 对端确实持有证书对应私钥;
- 当前握手没有被替换为另一个未经授权的服务端。
需要注意:证书私钥主要用于签名认证,不等于“用 RSA 私钥解密所有 HTTPS 数据”。现代 TLS 1.3 使用临时密钥交换产生会话秘密。
第六步:客户端验证证书链
客户端检查:
- SAN 是否包含访问域名;
- 当前时间是否在证书有效期内;
- 证书是否能够通过中间 CA 链到受信任根;
- 数字签名是否有效;
- 用途和策略是否允许;
- 客户端生态是否认为该证书已被吊销。
第七步:双方发送 Finished
Finished 消息用于确认握手消息完整性。完成后,双方使用派生的对称密钥传输 HTTP 数据。
2.3 单向陷门与 ECDHE 的核心
ECDHE 的安全性依赖椭圆曲线离散对数问题。
客户端随机生成临时私钥 a,计算公开点:
A = aG服务端生成临时私钥 b,计算:
B = bG双方分别计算:
客户端:aB = abG服务端:bA = abG网络观察者能够获得 G、A、B,但无法在可行时间内从 A = aG 反推出 a,也无法从 B = bG 反推出 b。
这里的“单向”不是数学上绝对不可逆,而是当前参数规模下计算成本不可接受。
因为 a 和 b 是临时密钥,握手结束后应被销毁,所以即使服务端证书私钥未来泄露,攻击者通常也不能仅凭历史抓包还原过去的会话密钥,这就是前向保密。
2.4 TLS 1.2 与 TLS 1.3 的实践边界
目前通用生产配置通常保留:
ssl_protocols TLSv1.2 TLSv1.3;原因是:
- TLS 1.3 更现代、握手更精简;
- TLS 1.2 仍用于兼容部分旧客户端;
- TLS 1.0 和 TLS 1.1 不应作为新的通用生产基线。
ssl_ciphers 主要控制 TLS 1.2 及更早版本的密码套件。TLS 1.3 密码套件由 OpenSSL 的 TLS 1.3 配置机制管理,不应误以为一条旧式 ssl_ciphers 就能控制所有版本。
不要直接复制多年以前包含 RC4、3DES、静态 RSA 密钥交换或 TLS 1.0 的“万能配置”。
2.5 0-RTT 为什么默认应谨慎
TLS 1.3 会话恢复可以支持 Early Data,即客户端在握手完全确认前发送数据。
优势:降低延迟。
风险:Early Data 可能被重放。
因此:
- 不要把订单创建、扣款、发券、修改密码等非幂等请求放入 0-RTT;
- 应用必须能够识别和拒绝不安全的 Early Data;
- 未明确理解重放防护前,应保持
ssl_early_data关闭。
三、证书链、公钥、私钥与域名验证
3.1 证书不是“加密文件”
X.509 证书是一个经过 CA 签名的身份声明,主要包含:
- Subject Alternative Name;
- 公钥;
- 签发者;
- 序列号;
- 有效期;
- Key Usage / Extended Key Usage;
- CA 数字签名。
证书通常可以公开发送。真正必须保密的是私钥。
3.2 叶子证书、中间证书与根证书
独立插图 03:证书链与客户端信任验证
对应文件:03-certificate-chain-validation.png

叶子证书
也叫服务器证书,直接对应你的域名,例如:
api.example.com中间 CA 证书
由根 CA 或更上级中间 CA 签发,用来签发叶子证书。服务端通常需要把中间证书随叶子证书一起发送。
根 CA 证书
根证书通常已经预装在操作系统、浏览器或 Java TrustStore 中。服务端通常不需要发送根证书。
3.3 fullchain.pem 是什么
常见 ACME 客户端生成:
cert.pem 叶子证书chain.pem 中间证书链fullchain.pem 叶子证书 + 中间证书链privkey.pem 私钥Nginx 通常配置:
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;如果只配置叶子证书,部分客户端可能无法构建完整信任链。
3.4 SAN 而不是只看 Common Name
现代客户端主要根据 SAN 验证域名。
例如访问:
https://api.example.com证书 SAN 至少需要包含:
DNS:api.example.com通配符:
*.example.com通常只能匹配一级子域,例如:
api.example.com 可匹配v1.api.example.com 通常不可匹配example.com 不匹配3.5 证书与私钥必须匹配
可以通过公钥摘要检查。
RSA 示例:
openssl x509 -in fullchain.pem -noout -pubkey \ | openssl pkey -pubin -outform pem \ | sha256sum
openssl pkey -in privkey.pem -pubout -outform pem \ | sha256sum两个摘要应一致。
查看证书信息:
openssl x509 -in fullchain.pem -noout -subject -issuer -dates -ext subjectAltName检查私钥:
openssl pkey -in privkey.pem -check -noout3.6 私钥保护
私钥保护至少包括:
- 文件权限最小化;
- 不提交到 Git;
- 不打进公开镜像层;
- 容器中只读挂载;
- 不在日志中打印;
- 备份应加密;
- 轮换和吊销流程可执行;
- 限制能读取证书 Secret 的账号;
- 云环境优先使用受控证书服务或 Secret 管理系统。
私钥一旦泄露,攻击者可能冒充服务端。在发现泄露后,仅删除旧文件不够,还应吊销证书、轮换私钥并排查泄露范围。
四、Nginx HTTPS 配置与请求处理流程
4.1 TLS 握手发生在 HTTP 路由之前
独立插图 04:Nginx SNI、证书、ALPN 与 HTTP 请求处理流程
对应文件:04-nginx-tls-sni-alpn-flow.png
基本顺序是:
TCP 连接 -> ClientHello -> 根据监听地址和 SNI 选择 HTTPS 虚拟主机/证书 -> TLS 握手 -> ALPN 确定 HTTP/2 或 HTTP/1.1 -> 解密 HTTP 请求 -> server/location 匹配和业务处理因此,证书错误发生时,应用接口通常还没有获得请求。
4.2 最小 HTTPS 配置
server { listen 443 ssl; http2 on;
server_name api.example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem; ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / { proxy_pass http://backend_cluster; }}listen 443 ssl
表示该监听端口使用 TLS。
http2 on
从 Nginx 1.25.1 开始,HTTP/2 使用独立 http2 on; 指令启用。新的文章和配置不应继续把:
listen 443 ssl http2;作为推荐写法。
旧写法在某些版本仍可能兼容,但会产生弃用提示。
ssl_certificate
指定发送给客户端的证书链。
ssl_certificate_key
指定与叶子证书公钥匹配的私钥。
ssl_protocols
限制允许的 TLS 协议版本。
4.3 SNI 与多个 HTTPS 域名
同一个 IP 和 443 端口可以承载多个 HTTPS 域名:
server { listen 443 ssl; server_name api.example.com;
ssl_certificate /certs/api/fullchain.pem; ssl_certificate_key /certs/api/privkey.pem;}
server { listen 443 ssl; server_name admin.example.com;
ssl_certificate /certs/admin/fullchain.pem; ssl_certificate_key /certs/admin/privkey.pem;}客户端在 ClientHello 中发送 SNI,Nginx 据此选择证书。
如果客户端没有发送 SNI,Nginx 只能使用该监听地址上的默认 HTTPS server 证书。这也是为什么错误的默认虚拟主机可能导致“访问 A 域名却收到 B 域名证书”。
4.4 ALPN 与 HTTP/2
ALPN 是 TLS 扩展,用于协商上层协议。
客户端可能声明支持:
h2http/1.1Nginx 完成 TLS 握手时选择一个协议。可以记录:
log_format tls '$remote_addr $host $request ' 'status=$status protocol=$server_protocol ' 'tls=$ssl_protocol cipher=$ssl_cipher ' 'sni=$ssl_server_name http2=$http2';4.5 TLS Session Cache
TLS 握手中的证书验证和密钥计算存在成本。Session Resumption 可以减少重复完整握手。
共享 Session Cache:
ssl_session_cache shared:TLS:20m;ssl_session_timeout 10m;官方文档说明,共享缓存每 1MB 大约可容纳数千个会话,实际容量受实现和会话数据影响。
共享缓存能够让不同 Worker 复用会话状态。
4.6 Session Ticket
ssl_session_tickets on;Session Ticket 把会话状态交给客户端保存,服务端使用 Ticket Key 加解密。
需要关注:
- 多 Nginx 节点需要一致的 Ticket Key 才能跨节点恢复;
- 长期静态 Ticket Key 会扩大密钥泄露影响;
- Ticket Key 需要安全轮换;
- 不具备轮换能力时可以关闭 Ticket,接受更多完整握手;
- TLS 1.3 会话恢复和 Early Data 也需要理解 Ticket/PSK 语义。
简化基线可以使用:
ssl_session_cache shared:TLS:20m;ssl_session_timeout 10m;ssl_session_tickets off;这不是所有场景的性能最优解,而是降低 Ticket Key 运维复杂度的选择。
4.7 OCSP Stapling 的当前边界
Nginx 支持:
ssl_stapling on;ssl_stapling_verify on;ssl_trusted_certificate /etc/nginx/certs/ca-chain.pem;resolver 1.1.1.1 8.8.8.8 valid=300s;但是否有意义取决于证书签发 CA 是否提供 OCSP Responder。
截至 2026 年,Let’s Encrypt 已停止 OCSP 服务,并改用 CRL 发布吊销信息。因此,对当前 Let’s Encrypt 证书照搬 ssl_stapling on 不会获得有效 OCSP 响应。
正确原则是:
- 检查证书 AIA 是否包含 OCSP URL;
- 检查签发 CA 当前是否仍提供 OCSP;
- 不要把 OCSP Stapling 当成所有证书的固定模板;
- 不要继续请求 Let’s Encrypt OCSP Must-Staple 证书。
4.8 HTTP/3 不是简单加一行配置
Nginx 从 1.25.0 开始提供实验性的 ngx_http_v3_module。
需要注意:
- 模块并非所有构建默认包含;
- 使用 QUIC/UDP 而不是 TCP;
- 需要 UDP 443、防火墙和云 LB 支持;
- 常通过
Alt-Svc告知客户端; - OpenSSL 和 Nginx 构建方式影响 0-RTT 等能力;
- 官方仍将该模块标为实验性。
因此,本篇稳定版实验只启用 HTTPS 与 HTTP/2,不把 HTTP/3 作为默认生产基线。
五、HTTP 跳转 HTTPS 与 HSTS
5.1 HTTP 重定向和 HSTS 不是同一件事
独立插图 05:HTTP 跳转 HTTPS 与 HSTS 请求流程
对应文件:05-http-redirect-hsts-flow.png
HTTP 重定向处理当前请求:
客户端先发送 HTTP -> 服务端返回 Location: https://... -> 客户端重新发起 HTTPSHSTS 改变浏览器未来请求:
浏览器已经通过 HTTPS 收到 HSTS -> 在有效期内输入 http:// 时,本地直接升级为 https://5.2 跳转配置
生产环境常见:
server { listen 80; server_name example.com www.example.com;
return 308 https://$host$request_uri;}$request_uri 保留原始路径和查询参数。
5.3 301、302、307、308 的差异
| 状态码 | 永久性 | 方法和请求体语义 |
|---|---|---|
| 301 | 永久 | 历史客户端可能把 POST 改成 GET |
| 302 | 临时 | 历史客户端可能把 POST 改成 GET |
| 307 | 临时 | 明确保留方法和请求体 |
| 308 | 永久 | 明确保留方法和请求体 |
对于网页 GET,301/308 都常见。
对于 API,如果不能接受 POST 被转换为 GET,应优先考虑 307/308。最稳妥的方式仍是让客户端直接使用 HTTPS API 地址,不依赖明文入口跳转。
5.4 HSTS 基础配置
add_header Strict-Transport-Security "max-age=31536000" always;HSTS 只能通过 HTTPS 响应发送。浏览器会忽略 HTTP 响应中的 HSTS。
5.5 includeSubDomains 的风险
add_header Strict-Transport-Security \ "max-age=31536000; includeSubDomains" always;这表示所有子域都必须支持 HTTPS。
如果以下任意子域仍只支持 HTTP:
legacy.example.comprinter.example.cominternal.example.com浏览器可能强制使用 HTTPS,导致业务不可访问。
5.6 preload 的风险
add_header Strict-Transport-Security \ "max-age=63072000; includeSubDomains; preload" always;preload 不是普通运行参数,而是申请加入浏览器预加载列表的承诺。
启用前必须确认:
- 主域和所有现有子域都支持 HTTPS;
- 未来新子域也会默认支持 HTTPS;
- 证书自动化成熟;
- 不存在依赖明文 HTTP 的设备或旧系统;
- 团队理解移除 preload 并不能立即影响所有客户端。
建议按阶段上线:
max-age=300 -> 1 天 -> 1 周 -> 1 月 -> 1 年 -> includeSubDomains -> 最后才考虑 preload5.7 重定向循环
常见结构:
客户端 HTTPS -> 云 LB 终止 TLS -> HTTP 转发给 Nginx -> Nginx 看到 $scheme=http,再跳转 HTTPS -> 客户端重新访问 HTTPS -> 循环根因是 Nginx 没有正确理解原始协议。
修复思路:
- 只信任明确的上游负载均衡器;
- 使用受控的
X-Forwarded-Proto; - 云 LB 与 Nginx 的转发协议配置一致;
- 不信任任意客户端伪造的转发 Header。
六、ACME、Certbot 与证书自动续期
6.1 为什么证书必须自动化
证书有有效期。手工续期存在:
- 忘记续期;
- 私钥和证书路径写错;
- 多节点只更新一台;
- 更新后没有 reload;
- reload 失败仍以为已经生效;
- 证书文件更新但 Nginx 仍加载旧证书;
- 到期前没有告警。
完整自动化应形成闭环。
独立插图 06:ACME 签发、部署、续期与 reload 闭环
对应文件:06-acme-certificate-lifecycle.png

6.2 ACME 的作用
ACME 协议让客户端自动完成:
- 创建 CA 订单;
- 证明域名控制权;
- 提交 CSR;
- 获取证书;
- 周期续期;
- 必要时吊销。
6.3 HTTP-01
CA 要求在以下路径提供 Token:
http://example.com/.well-known/acme-challenge/<token>特点:
- 需要公网可访问 80 端口;
- 不能签发通配符证书;
- 多入口节点需要共享 Challenge 文件或统一路由;
- 适合单域名和普通 Web 服务。
Nginx 常见配置:
server { listen 80; server_name example.com www.example.com;
location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; default_type text/plain; try_files $uri =404; }
location / { return 308 https://$host$request_uri; }}不要先把所有 HTTP 请求无条件跳转,导致 Challenge 路径不可达。
6.4 DNS-01
CA 要求在 DNS 中创建:
_acme-challenge.example.com TXT <value>特点:
- 可以签发通配符证书;
- 不要求目标 Web 服务开放 80;
- 需要 DNS API 自动化;
- DNS API 凭证必须最小权限;
- 可以把
_acme-challenge委托到单独验证区域。
不要把拥有整个 DNS 账号全部权限的长期密钥直接放进公网 Nginx 容器。
6.5 TLS-ALPN-01
通过 443 端口和专用 ALPN 协议证明控制权。
适合某些无法使用 80 的场景,但需要入口能够正确处理 Challenge。实际支持情况取决于 ACME 客户端、CA 和部署方式。
6.6 Certbot 自动续期
Certbot 是常见 ACME 客户端。
Webroot 模式示例:
certbot certonly \ --webroot \ --webroot-path /var/www/certbot \ --domain example.com \ --domain www.example.com \ --email ops@example.com \ --agree-tos \ --no-eff-email测试续期:
certbot renew --dry-run续期成功后验证并 reload:
certbot renew \ --deploy-hook 'nginx -t && nginx -s reload'生产中应确认:
- Hook 运行环境能访问正确的 Nginx;
- 容器模式下不能假设 Certbot 容器中的
nginx是入口容器; - 可以通过共享证书卷、宿主机脚本或编排系统发送 reload;
- reload 失败应告警而不是静默忽略。
6.7 Nginx 原生 ACME 模块的当前边界
Nginx 已提供可选的 ngx_http_acme_module,实现 ACMEv2,并从 1.29.0 起提供预编译模块包。
需要注意:
- 它是可选模块,不应假设所有 Nginx 安装或官方基础镜像默认包含;
- 需要安装相应模块包或自行构建;
- 需要持久化
state_path,避免重启丢失状态和触发 CA 限速; - 当前模块支持的 Challenge 类型和功能应以实际模块版本文档为准;
- 迁移前需要评估与现有 Certbot、Secret 管理和发布流程的兼容性。
示意配置:
load_module modules/ngx_http_acme_module.so;
http { resolver 1.1.1.1;
acme_issuer letsencrypt { uri https://acme-v02.api.letsencrypt.org/directory; contact ops@example.com; state_path /var/cache/nginx/acme-letsencrypt; accept_terms_of_service; }
acme_shared_zone zone=ngx_acme_shared:1M;
server { listen 443 ssl; server_name example.com;
acme_certificate letsencrypt;
ssl_certificate $acme_certificate; ssl_certificate_key $acme_certificate_key; ssl_certificate_cache max=10; }}本文实验仍使用静态本地证书,因为它更适合离线复现 TLS 原理;生产环境再接入 Certbot、Nginx ACME 模块、云证书服务或 Kubernetes cert-manager。
6.8 多 Nginx 节点的证书同步
双机或多实例场景要解决:
- 证书由谁申请;
- 私钥在哪里生成;
- 如何安全同步;
- reload 是否逐节点执行;
- 某节点更新失败如何回滚;
- 证书和 Ticket Key 是否一致;
- ACME Challenge 如何路由;
- 是否使用云证书托管避免分发私钥。
不建议让每个节点对同一域名完全独立、无协调地频繁申请证书,容易触发 CA 限速并增加状态不一致。
七、TLS 应该在哪里终止
独立插图 07:TLS 终止与再加密部署模式
对应文件:07-tls-termination-patterns.png

7.1 Nginx 终止 TLS
Client === TLS ===> Nginx -- HTTP --> App优点:
- 配置直观;
- 证书集中;
- 应用无需承担 TLS 握手;
- 易于统一安全 Header 和日志。
风险:
- Nginx 到应用是明文;
- 内部网络不可信时可能不满足要求;
- 应用必须通过可信 Header 识别原始 HTTPS。
7.2 云 LB 终止 TLS
Client === TLS ===> Cloud LB -- HTTP --> Nginx -- HTTP --> App优点:
- 托管证书;
- 自动扩容;
- 与云 WAF、DDoS 防护结合;
- 降低自维护入口压力。
风险:
- Nginx 看到的
$scheme可能是 HTTP; - 真实 IP 和协议依赖可信代理 Header;
- 错误配置可能造成重定向循环;
- 云 LB 到 Nginx 的网络仍需评估。
7.3 Nginx 到后端再加密
Client === TLS ===> Nginx === TLS ===> App配置示意:
location /api/ { proxy_pass https://backend_https;
proxy_ssl_server_name on; proxy_ssl_name backend.internal.example;
proxy_ssl_verify on; proxy_ssl_trusted_certificate /etc/nginx/ca/internal-root.pem; proxy_ssl_verify_depth 2;}不要使用:
proxy_ssl_verify off;来“解决”内部证书报错。关闭验证只获得加密,不获得可信身份验证,会把中间人攻击风险带回内部链路。
7.4 mTLS
独立插图 12:mTLS 客户端证书与信任边界
对应文件:12-mtls-trust-boundary.png
Nginx 验证客户端证书:
ssl_client_certificate /etc/nginx/ca/client-ca.pem;ssl_verify_client on;ssl_verify_depth 2;可以记录:
$ssl_client_verify$ssl_client_s_dn$ssl_client_serial但要注意:
- mTLS 识别的是证书身份,不自动等于业务用户权限;
- 不要把证书 Subject 未经规范化直接当作管理员权限;
- 转发到后端的身份 Header 必须由 Nginx 覆盖,不能让公网客户端自行注入;
- 需要处理证书签发、轮换、吊销和根 CA 迁移;
- 普通浏览器用户场景通常仍使用账号、OIDC、Cookie 或 Token,而不是客户端证书。
7.5 选择矩阵
| 场景 | 推荐方式 |
|---|---|
| 单机个人项目 | Nginx 终止 TLS |
| 云上公网应用 | 云 LB/CDN 终止或云 LB + Nginx 再加密 |
| 跨不可信网络 | 端到端 TLS |
| 强合规内部系统 | Nginx 到应用再加密,必要时 mTLS |
| Kubernetes | Gateway/Ingress 终止,证书交给 cert-manager 或云托管 |
| 机器对机器高信任接口 | mTLS + 应用级授权 |
八、安全 Header:哪些适合 Nginx,哪些属于应用
独立插图 08:安全 Header 的入口层与应用层职责边界
对应文件:08-security-headers-responsibility.png

8.1 add_header 的继承陷阱
add_header X-Content-Type-Options nosniff always;always 表示不仅对部分 2xx/3xx 响应添加,也对更多状态码添加。
需要注意:在子级上下文重新声明任何 add_header 时,父级的 add_header 可能不再按直觉继承。应使用统一 snippet,或检查 nginx -T 展开的最终配置。
8.2 X-Content-Type-Options
add_header X-Content-Type-Options "nosniff" always;作用是让浏览器尊重服务端声明的 MIME 类型,减少 MIME Sniffing。
前提是服务端自身必须返回正确 Content-Type,否则 nosniff 可能暴露原本被浏览器“猜测修复”的配置错误。
8.3 Referrer-Policy
add_header Referrer-Policy "strict-origin-when-cross-origin" always;控制浏览器跨页面请求时发送多少 Referer 信息。
选择策略需要考虑:
- 是否依赖完整 Referer 做统计;
- URL 中是否有敏感参数;
- 跨域请求是否需要来源信息;
- 前端分析和安全需求。
8.4 Permissions-Policy
add_header Permissions-Policy \ "camera=(), microphone=(), geolocation=()" always;用于限制页面或 iframe 使用浏览器能力。
不要盲目禁止业务确实需要的相机、麦克风或地理位置能力。
8.5 Frame 防护
传统配置:
add_header X-Frame-Options "DENY" always;更现代、粒度更高的方式是 CSP:
Content-Security-Policy: frame-ancestors 'none'如果应用存在合法嵌入场景,不能全局设置 DENY。CSP 的 frame-ancestors 更适合按页面和业务来源控制。
8.6 Content-Security-Policy
静态站点简单基线:
add_header Content-Security-Policy \ "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;但复杂前端通常包含:
- 动态脚本;
- 第三方统计;
- CDN;
- WebSocket;
- 图片和字体域名;
- 内联脚本;
- nonce 或 hash。
因此 CSP 往往需要应用为每次响应生成 nonce:
Content-Security-Policy: script-src 'self' 'nonce-<random>'Nginx 静态配置不适合独立生成与页面模板同步的动态 nonce。
建议先使用:
Content-Security-Policy-Report-Only观察违规,再逐步强制。
8.7 Cookie 安全属性
Cookie 的:
SecureHttpOnlySameSitePathDomainMax-Age更适合由应用或认证网关按具体 Cookie 语义生成。
Nginx 可以通过代理重写 Cookie 属性,但全局修改容易误伤:
- OAuth 回调;
- 跨站嵌入;
- 多子域会话;
- 第三方身份提供商;
- 不同 Cookie 的 SameSite 要求。
8.8 HSTS 适合入口统一,但需要组织级确认
HSTS 看起来适合 Nginx 全局添加,但 includeSubDomains 会影响整个域名体系。因此它不是单个服务团队随意决定的 Header,而是域名治理决策。
九、基础入口防护与 Host 边界
独立插图 10:Nginx 安全入口的分层防护
对应文件:10-nginx-security-entry-layers.png

9.1 默认 HTTPS server
应该拒绝未知 Host/SNI:
server { listen 443 ssl default_server; http2 on;
server_name _;
ssl_certificate /etc/nginx/certs/default.crt; ssl_certificate_key /etc/nginx/certs/default.key;
return 444;}注意:TLS 握手需要先选择某张证书,所以默认 HTTPS server 仍需要可用证书。使用什么默认证书取决于部署和客户端兼容策略。
9.2 Host 校验
不要把任意客户端 Host 用于:
- 密码重置链接;
- OAuth 回调地址;
- 邮件链接;
- 绝对 URL;
- 多租户映射。
入口应只接受预期域名:
server_name api.example.com;应用生成绝对链接时还要使用可信配置,而不是直接信任请求 Host。
9.3 请求体大小
client_max_body_size 10m;作用:限制单个请求体大小。
它不是所有接口都应该统一为 10MB。上传接口、普通 JSON API、Webhook 和管理接口应有不同上限。
超限通常返回 413。
9.4 请求方法限制
静态资源可以限制:
location /assets/ { limit_except GET HEAD { deny all; }}API 方法应由应用路由和授权最终判断。不要在 Nginx 中维护一套与应用完全重复、容易漂移的方法矩阵。
9.5 隐藏文件和敏感路径
location ~ /\. { deny all;}但需要为 ACME Challenge 留出例外:
location ^~ /.well-known/acme-challenge/ { root /var/www/certbot;}同时防止误暴露:
.git;.env;- 私钥;
- 数据库备份;
- Spring Boot 配置;
- 调试文件;
- 管理接口。
9.6 Server Header
server_tokens off;可以减少响应中直接暴露精确版本,但它不是安全补丁,也不能隐藏所有实现特征。真正重要的是及时升级和减少攻击面。
9.7 WAF 边界
Nginx 原生配置可以做基础路径、大小、方法、速率限制,但不等于完整 WAF。
复杂的:
- SQL 注入检测;
- XSS 特征;
- Bot 管理;
- 虚拟补丁;
- 威胁情报;
- 大规模 DDoS;
通常需要云 WAF、专用 WAF 模块或上层安全服务。
十、CORS:浏览器读取权限,不是接口鉴权
独立插图 09:CORS 预检与凭证请求流程
对应文件:09-cors-preflight-credentials.png

10.1 什么是 Origin
Origin 由:
scheme + host + port组成。
以下 Origin 不相同:
https://app.example.comhttp://app.example.comhttps://app.example.com:8443https://api.example.com10.2 CORS 控制什么
CORS 控制浏览器中的脚本是否可以读取跨 Origin 响应。
它不阻止:
- curl;
- Postman;
- 后端服务;
- 恶意脚本直接向接口发送请求;
- 已泄露 Token 被服务端调用。
所以 CORS 不能代替:
- 身份认证;
- 权限校验;
- API Key;
- CSRF;
- 速率限制。
10.3 简单请求和预检请求
当请求方法、Header 或 Content-Type 超出简单请求范围时,浏览器先发送:
OPTIONS /api/orders HTTP/1.1Origin: https://app.example.comAccess-Control-Request-Method: POSTAccess-Control-Request-Headers: Authorization, Content-Type服务端返回:
HTTP/1.1 204 No ContentAccess-Control-Allow-Origin: https://app.example.comAccess-Control-Allow-Methods: GET, POST, OPTIONSAccess-Control-Allow-Headers: Authorization, Content-TypeAccess-Control-Allow-Credentials: trueVary: Origin预检成功后,浏览器才发送真实请求。
10.4 凭证与通配符不能组合
错误配置:
Access-Control-Allow-Origin: *Access-Control-Allow-Credentials: true浏览器不会接受带凭证的通配符 Origin。
需要根据白名单返回明确 Origin:
Access-Control-Allow-Origin: https://app.example.comAccess-Control-Allow-Credentials: trueVary: Origin10.5 使用 map 构造 Origin 白名单
map $http_origin $cors_origin { default "";
"https://app.example.com" $http_origin; "https://admin.example.com" $http_origin;}预检示意:
location /api/ { if ($request_method = OPTIONS) { add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Credentials "true" always; add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always; add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Request-ID" always; add_header Vary "Origin" always; add_header Content-Length 0; return 204; }
add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Credentials "true" always; add_header Vary "Origin" always;
proxy_pass http://backend;}这里 if 只执行 return,行为较可控。但仍应注意:
- 非白名单 Origin 时变量为空,必须确认实际响应行为;
add_header继承规则容易导致重复或丢失;- 应用也添加 CORS 时可能出现多个
Access-Control-Allow-Origin; - 复杂租户、动态域名和不同接口策略更适合由应用或 API Gateway 统一处理;
- 带 Cookie 的跨站请求还需要 CSRF 防护。
10.6 CORS 与缓存
当响应根据 Origin 返回不同 Access-Control-Allow-Origin 时,应发送:
Vary: Origin否则缓存可能把为 A Origin 生成的响应错误提供给 B Origin。
十一、最小可运行实验
本实验完成:
- 本地根 CA;
- 为
localhost和dev.example.test签发服务器证书; - Nginx 80 端口跳转到 8443;
- Nginx 8443 完成 TLS;
- HTTP/2 配置;
- 反向代理到 Spring Boot;
- 传递原始协议和客户端信息;
- 安全 Header;
- 使用
curl --cacert和openssl s_client验证。
11.1 项目结构
nginx-https-lab/├── compose.yaml├── backend/│ ├── Dockerfile│ ├── pom.xml│ └── src/main/java/com/example/httpslab/│ ├── HttpsLabApplication.java│ └── InfoController.java├── nginx/│ ├── nginx.conf│ ├── conf.d/│ │ └── app.conf│ ├── snippets/│ │ ├── tls.conf│ │ ├── proxy-headers.conf│ │ └── security-headers.conf│ └── html/│ └── index.html├── certs/│ ├── rootCA.crt│ ├── rootCA.key│ ├── server.crt│ └── server.key└── scripts/ ├── generate-local-cert.sh └── verify.sh11.2 Spring Boot pom.xml
路径:backend/pom.xml
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion>
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>4.1.0</version> <relativePath/> </parent>
<groupId>com.example</groupId> <artifactId>nginx-https-lab</artifactId> <version>0.0.1-SNAPSHOT</version>
<properties> <java.version>21</java.version> </properties>
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> </dependencies>
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build></project>11.3 启动类
路径:backend/src/main/java/com/example/httpslab/HttpsLabApplication.java
package com.example.httpslab;
import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplicationpublic class HttpsLabApplication { public static void main(String[] args) { SpringApplication.run(HttpsLabApplication.class, args); }}11.4 Header 观察接口
路径:backend/src/main/java/com/example/httpslab/InfoController.java
package com.example.httpslab;
import jakarta.servlet.http.HttpServletRequest;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;import java.util.LinkedHashMap;import java.util.Map;
@RestControllerpublic class InfoController {
@GetMapping("/api/info") public ResponseEntity<Map<String, Object>> info(HttpServletRequest request) { Map<String, Object> body = new LinkedHashMap<>(); body.put("time", Instant.now().toString()); body.put("schemeSeenByApp", request.getScheme()); body.put("serverNameSeenByApp", request.getServerName()); body.put("remoteAddrSeenByApp", request.getRemoteAddr()); body.put("host", request.getHeader("Host")); body.put("xForwardedProto", request.getHeader("X-Forwarded-Proto")); body.put("xForwardedHost", request.getHeader("X-Forwarded-Host")); body.put("xForwardedFor", request.getHeader("X-Forwarded-For")); body.put("xRequestId", request.getHeader("X-Request-ID")); return ResponseEntity.ok(body); }}11.5 Dockerfile
路径:backend/Dockerfile
FROM maven:3.9.11-eclipse-temurin-21 AS buildWORKDIR /workspace
COPY pom.xml .RUN mvn -q -DskipTests dependency:go-offline
COPY src ./srcRUN mvn -q -DskipTests package
FROM eclipse-temurin:21-jreWORKDIR /app
RUN useradd --system --uid 10001 appuserCOPY --from=build /workspace/target/nginx-https-lab-0.0.1-SNAPSHOT.jar app.jar
USER appuserEXPOSE 8080ENTRYPOINT ["java", "-jar", "/app/app.jar"]11.6 本地 CA 和服务器证书脚本
路径:scripts/generate-local-cert.sh
#!/usr/bin/env bashset -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"CERT_DIR="$ROOT_DIR/certs"mkdir -p "$CERT_DIR"
openssl genpkey \ -algorithm EC \ -pkeyopt ec_paramgen_curve:P-256 \ -out "$CERT_DIR/rootCA.key"
openssl req \ -x509 \ -new \ -sha256 \ -days 3650 \ -key "$CERT_DIR/rootCA.key" \ -subj "/C=SG/O=Nginx Column Lab/CN=Nginx Column Local Root CA" \ -out "$CERT_DIR/rootCA.crt"
openssl genpkey \ -algorithm EC \ -pkeyopt ec_paramgen_curve:P-256 \ -out "$CERT_DIR/server.key"
cat > "$CERT_DIR/server.ext" <<'EOF'basicConstraints=CA:FALSEkeyUsage=digitalSignature,keyEnciphermentextendedKeyUsage=serverAuthsubjectAltName=DNS:localhost,DNS:dev.example.test,IP:127.0.0.1EOF
openssl req \ -new \ -sha256 \ -key "$CERT_DIR/server.key" \ -subj "/C=SG/O=Nginx Column Lab/CN=dev.example.test" \ -out "$CERT_DIR/server.csr"
openssl x509 \ -req \ -sha256 \ -days 365 \ -in "$CERT_DIR/server.csr" \ -CA "$CERT_DIR/rootCA.crt" \ -CAkey "$CERT_DIR/rootCA.key" \ -CAcreateserial \ -extfile "$CERT_DIR/server.ext" \ -out "$CERT_DIR/server.crt"
chmod 600 "$CERT_DIR/rootCA.key" "$CERT_DIR/server.key"
openssl verify \ -CAfile "$CERT_DIR/rootCA.crt" \ "$CERT_DIR/server.crt"
echo "Generated certificates in $CERT_DIR"执行:
chmod +x scripts/generate-local-cert.sh./scripts/generate-local-cert.sh实验根 CA 只用于本地学习,不要用于生产。
11.7 Nginx 主配置
路径:nginx/nginx.conf
user nginx;worker_processes auto;
error_log /var/log/nginx/error.log notice;pid /var/run/nginx.pid;
events { worker_connections 1024;}
http { include /etc/nginx/mime.types; default_type application/octet-stream;
log_format tls_json escape=json '{' '"time":"$time_iso8601",' '"remote_addr":"$remote_addr",' '"host":"$host",' '"request":"$request",' '"status":$status,' '"request_time":$request_time,' '"tls_protocol":"$ssl_protocol",' '"tls_cipher":"$ssl_cipher",' '"sni":"$ssl_server_name",' '"http2":"$http2",' '"upstream_addr":"$upstream_addr",' '"upstream_status":"$upstream_status"' '}';
access_log /var/log/nginx/access.log tls_json;
sendfile on; keepalive_timeout 65s;
include /etc/nginx/conf.d/*.conf;}11.8 TLS snippet
路径:nginx/snippets/tls.conf
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:TLS:10m;ssl_session_timeout 10m;ssl_session_tickets off;
ssl_certificate /etc/nginx/certs/server.crt;ssl_certificate_key /etc/nginx/certs/server.key;本地 CA 不提供 OCSP,因此不配置 Stapling。
11.9 代理 Header snippet
路径:nginx/snippets/proxy-headers.conf
proxy_http_version 1.1;
proxy_set_header Host $host;proxy_set_header X-Real-IP $remote_addr;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;proxy_set_header X-Forwarded-Proto $scheme;proxy_set_header X-Forwarded-Host $host;proxy_set_header X-Request-ID $request_id;
proxy_connect_timeout 3s;proxy_send_timeout 15s;proxy_read_timeout 30s;11.10 安全 Header snippet
路径:nginx/snippets/security-headers.conf
add_header X-Content-Type-Options "nosniff" always;add_header Referrer-Policy "strict-origin-when-cross-origin" always;add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# 本地实验默认不启用 HSTS,防止浏览器长期记住测试域名。# 生产上线前应逐级增加 max-age,并确认全部子域支持 HTTPS。# add_header Strict-Transport-Security "max-age=300" always;11.11 站点配置
路径:nginx/conf.d/app.conf
upstream backend { server backend:8080;}
server { listen 80; server_name localhost dev.example.test;
return 308 https://$host:8443$request_uri;}
server { listen 443 ssl; http2 on;
server_name localhost dev.example.test;
include /etc/nginx/snippets/tls.conf; include /etc/nginx/snippets/security-headers.conf;
root /usr/share/nginx/html; index index.html;
location = / { try_files /index.html =404; }
location /api/ { include /etc/nginx/snippets/proxy-headers.conf; proxy_pass http://backend; }}本实验容器内监听 443,但宿主机映射为 8443,因此 HTTP 跳转显式加入 :8443。生产环境使用标准 443 时应删除该端口。
11.12 静态页面
路径:nginx/html/index.html
<!doctype html><html lang="zh-CN"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Nginx HTTPS Lab</title></head><body> <h1>Nginx HTTPS Lab</h1> <p>如果能够看到此页面,说明 TLS 握手和静态资源返回成功。</p> <p><a href="/api/info">查看代理 Header</a></p></body></html>11.13 Docker Compose
路径:compose.yaml
services: backend: build: context: ./backend expose: - "8080" networks: - lab restart: unless-stopped
nginx: image: nginx:1.30.3 depends_on: - backend ports: - "8080:80" - "8443:443" volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./nginx/snippets:/etc/nginx/snippets:ro - ./nginx/html:/usr/share/nginx/html:ro - ./certs:/etc/nginx/certs:ro networks: - lab restart: unless-stopped
networks: lab: driver: bridge11.14 启动
./scripts/generate-local-cert.sh
docker compose builddocker compose up -d11.15 配置检查
docker compose exec nginx nginx -t预期:
syntax is oktest is successful11.16 验证 HTTP 跳转
curl -I http://localhost:8080/api/info预期:
HTTP/1.1 308 Permanent RedirectLocation: https://localhost:8443/api/info11.17 验证 HTTPS
curl \ --cacert certs/rootCA.crt \ https://localhost:8443/api/info预期后端看到:
{ "schemeSeenByApp": "http", "xForwardedProto": "https", "host": "localhost", "xForwardedHost": "localhost"}schemeSeenByApp 仍可能是 HTTP,因为 Nginx 到 Spring Boot 使用 HTTP;应用应通过可信转发 Header 或 Forwarded Header 策略恢复外部协议。
11.18 验证证书链和 SNI
openssl s_client \ -connect localhost:8443 \ -servername dev.example.test \ -CAfile certs/rootCA.crt \ -alpn h2,http/1.1 \ -showcerts关注:
Verification: OKProtocol : TLSv1.3ALPN protocol: h2实际输出取决于 OpenSSL 和客户端支持。
11.19 验证错误域名
证书 SAN 不包含 wrong.example.test:
curl \ --resolve wrong.example.test:8443:127.0.0.1 \ --cacert certs/rootCA.crt \ https://wrong.example.test:8443/api/info应出现域名不匹配错误。
如果使用 -k 跳过校验,请仅用于排障,不要把它当作修复方案。
11.20 查看日志
docker compose logs -f nginx进入容器查看结构化访问日志:
docker compose exec nginx tail -f /var/log/nginx/access.log11.21 停止和清理
docker compose down --remove-orphans如不再使用本地 CA,应删除测试私钥:
rm -rf certs十二、从开发配置升级到生产配置
12.1 第一阶段:HTTPS 可用
目标:
- 证书与私钥正确;
- TLS 1.2/1.3;
- HTTP 跳转 HTTPS;
- 反向代理正常。
server { listen 443 ssl; http2 on;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3;
location / { proxy_pass http://backend; }}12.2 第二阶段:会话与连接性能
ssl_session_cache shared:TLS:20m;ssl_session_timeout 10m;ssl_session_tickets off;keepalive_timeout 65s;上线前通过压测观察:
- 完整握手比例;
- CPU;
- TLS 握手耗时;
- 连接复用;
- Session Resumption 命中。
12.3 第三阶段:协议与证书日志
log_format tls '$remote_addr $host "$request" $status ' 'tls=$ssl_protocol cipher=$ssl_cipher ' 'sni=$ssl_server_name h2=$http2 ' 'rt=$request_time urt=$upstream_response_time';用于识别:
- 旧 TLS 客户端;
- 异常 Cipher;
- 未发送 SNI;
- HTTP/2 协商率;
- TLS 正常但 upstream 变慢。
12.4 第四阶段:自动续期
必须包含:
定期 renew -> 证书文件原子更新 -> nginx -t -> reload -> 远程握手验证 -> 到期时间监控 -> 失败告警仅执行 certbot renew 不是完整上线流程。
12.5 第五阶段:安全 Header
先添加低风险、通用 Header:
add_header X-Content-Type-Options "nosniff" always;add_header Referrer-Policy "strict-origin-when-cross-origin" always;再根据业务验证:
- Permissions-Policy;
- Frame 防护;
- CSP Report-Only;
- HSTS 短
max-age; - 最后才是长期 HSTS 和 preload。
12.6 第六阶段:内部链路安全
根据威胁模型决定:
- Nginx 到后端 HTTP;
- Nginx 到后端 HTTPS;
- mTLS;
- Service Mesh mTLS;
- 云私有网络与安全组;
- 证书托管和 Secret 管理。
不要只因为“内网”就默认可信,也不要在低风险场景盲目增加所有链路 mTLS,导致证书运维超过团队能力。
12.7 第七阶段:发布和回滚
证书或 TLS 配置发布应:
- 在测试环境验证;
- 使用
nginx -t; - 分批 reload;
- 使用不同客户端验证 TLS 1.2、TLS 1.3、HTTP/2;
- 检查错误率和握手错误;
- 保留旧证书和配置回滚能力;
- 避免在证书到期当天才操作。
十三、常见错误与反例
13.1 只配置叶子证书
错误现象
部分浏览器可以访问,部分 Java 客户端或旧设备提示证书链不完整。
错误配置
ssl_certificate /certs/cert.pem;根本原因
服务端没有发送必要中间证书,客户端无法构建到受信任根的链。
修复
ssl_certificate /certs/fullchain.pem;验证
openssl s_client -connect example.com:443 -servername example.com -showcerts13.2 证书与私钥不匹配
错误现象
nginx -t 失败,出现 key values mismatch。
根本原因
证书公钥和私钥不是一对。
修复
使用公钥摘要比较,并重新选择正确私钥。
13.3 访问 IP,但证书只有域名
错误现象
证书链可信,但客户端提示 hostname mismatch。
根本原因
证书 SAN 中没有该 IP。
修复
使用证书覆盖的域名访问,或为需要的 IP 签发包含 IP SAN 的证书。
不要使用 -k 作为生产修复。
13.4 开启 HSTS preload 后仍有 HTTP 子域
错误现象
旧系统突然无法访问,浏览器自动尝试 HTTPS。
根本原因
includeSubDomains; preload 将策略扩展到整个子域体系。
修复
启用前完成域名资产盘点。已 preload 后的恢复需要长期兼容处理,不能指望立刻撤销。
13.5 HTTP 跳转循环
错误现象
浏览器提示 Too Many Redirects。
根本原因
TLS 在云 LB 终止,Nginx 收到 HTTP 后再次跳转,未正确恢复原始协议。
修复
只信任受控代理的 X-Forwarded-Proto,统一链路协议策略。
13.6 关闭后端证书验证
错误配置
proxy_ssl_verify off;根本原因
为绕过自签名或主机名错误,关闭了服务端身份验证。
风险
内部攻击者可以冒充后端。
修复
配置内部 CA、正确 SNI 和可信证书:
proxy_ssl_server_name on;proxy_ssl_name backend.internal.example;proxy_ssl_verify on;proxy_ssl_trusted_certificate /etc/nginx/ca/internal-root.pem;13.7 把 Let’s Encrypt OCSP Stapling 当固定模板
错误现象
Nginx 日志持续出现 OCSP Responder 相关错误,或配置没有任何实际效果。
根本原因
Let’s Encrypt 已于 2025 年停止 OCSP 服务。
修复
按实际 CA 能力决定,不对当前 Let’s Encrypt 证书启用无效的 OCSP Stapling。
13.8 证书续期成功但线上仍是旧证书
可能原因
- Nginx 没有 reload;
- reload 失败;
- 实际加载的是另一条路径;
- 云 LB/CDN 终止 TLS,更新错了层;
- 多实例只更新一台;
- 容器挂载的是旧卷;
- symlink 更新但容器没有看到。
验证
openssl s_client \ -connect example.com:443 \ -servername example.com \ </dev/null 2>/dev/null \ | openssl x509 -noout -serial -dates -issuer -subject13.9 CORS 使用 * 和 Credentials
错误配置
Access-Control-Allow-Origin: *Access-Control-Allow-Credentials: true结果
浏览器拒绝带凭证的跨域响应。
修复
返回明确白名单 Origin,并设置 Vary: Origin。
13.10 Nginx 和应用重复添加 CORS Header
错误现象
响应出现两个 Access-Control-Allow-Origin,浏览器拒绝。
修复
明确只有一个层负责 CORS,或在代理层隐藏并重建后端 Header:
proxy_hide_header Access-Control-Allow-Origin;是否隐藏需要结合整体策略,不应盲目添加。
13.11 全局 CSP 导致前端白屏
根本原因
静态 CSP 未覆盖脚本、字体、图片、API 或 WebSocket 来源。
修复
先使用 Report-Only,收集违规;需要 nonce 时交给应用动态生成。
13.12 证书文件权限过宽
错误配置
chmod 777 privkey.pem风险
任何本机用户或容器进程都可能读取私钥。
修复
使用最小权限、只读挂载、受控用户和 Secret 管理。
十四、HTTPS 故障排查流程
独立插图 11:HTTPS 分层故障排查流程
对应文件:11-https-troubleshooting-flow.png

14.1 第一层:DNS
dig +short example.com确认:
- 是否解析到正确入口;
- A/AAAA 是否一致;
- CDN 是否启用;
- 本地缓存是否陈旧;
/etc/hosts是否覆盖。
14.2 第二层:TCP 和端口
nc -vz example.com 443ss -lntp | grep ':443'检查:
- 安全组;
- 防火墙;
- 云 LB Listener;
- Docker 端口映射;
- Nginx 是否监听正确地址;
- IPv4/IPv6。
14.3 第三层:TLS 握手
openssl s_client \ -connect example.com:443 \ -servername example.com \ -alpn h2,http/1.1 \ -showcerts关注:
- 协议版本;
- Cipher;
- ALPN;
- 证书 Subject/SAN;
- Issuer;
- Verify return code;
- 证书链;
- 有效期。
14.4 第四层:SNI 和证书选择
对同一 IP 测试指定域名:
curl --resolve api.example.com:443:203.0.113.10 \ https://api.example.com/如果不带正确 SNI,可能收到默认 server 证书。
14.5 第五层:HTTP 重定向
curl -vI http://example.com/path?q=1curl -vI https://example.com/path?q=1检查:
- Location 是否正确;
- 路径和查询参数是否保留;
- 是否循环;
- 是否错误添加端口;
- API 方法是否会被改变。
14.6 第六层:安全 Header 和 CORS
curl -I https://example.com/
curl -i -X OPTIONS https://api.example.com/orders \ -H 'Origin: https://app.example.com' \ -H 'Access-Control-Request-Method: POST' \ -H 'Access-Control-Request-Headers: authorization,content-type'检查:
- 是否重复 Header;
Vary: Origin;- 凭证与通配符;
- 预检状态码;
- CSP 是否阻止资源;
- Header 是否在错误响应中仍存在。
14.7 第七层:upstream
HTTPS 握手成功但 API 502/504,问题可能在 Nginx 到后端:
docker compose exec nginx curl -v http://backend:8080/api/info检查:
- upstream DNS;
- 端口;
- 后端证书;
proxy_ssl_name;- 连接和读取超时;
- 后端应用日志。
14.8 第八层:证书续期
检查本地证书:
openssl x509 -in fullchain.pem -noout -dates -serial检查线上实际证书:
openssl s_client \ -connect example.com:443 \ -servername example.com \ </dev/null 2>/dev/null \ | openssl x509 -noout -dates -serial对比序列号,确认实际入口已经加载新证书。
14.9 Nginx 配置与日志
nginx -tnginx -Tnginx -s reload容器:
docker compose logs nginxdocker compose exec nginx nginx -T常见 error log:
SSL_CTX_use_PrivateKey failed;key values mismatch;no suitable key share;peer closed connection in SSL handshake;certificate verify failed;upstream SSL certificate verify error;too many redirects通常在客户端观察。
十五、生产实践与能力边界
15.1 什么规模下 Nginx 自管证书足够
适合:
- 少量域名;
- 单机或少量虚拟机;
- 团队具备 Linux 和证书运维能力;
- 可以稳定执行 ACME、reload 和告警;
- 证书私钥分发范围可控。
15.2 什么时候使用云证书服务
- 多地域;
- 多云 LB;
- 大量域名;
- 需要托管续期;
- 希望私钥不落到应用主机;
- CDN/WAF 已经终止 TLS;
- 需要统一审计和权限管理。
15.3 什么时候需要 mTLS
- 机器对机器;
- 管理面;
- 高价值内部 API;
- 零信任网络;
- 合规要求双向认证。
不适合:
- 普通公网用户登录;
- 团队没有证书生命周期管理;
- 无法处理移动端证书分发;
- 把证书 Subject 直接当业务权限。
15.4 什么时候需要 WAF
- 公网高风险业务;
- 频繁遭受自动化攻击;
- 需要托管规则和威胁情报;
- 合规要求;
- 应用修复周期较长,需要临时虚拟补丁。
Nginx 基础限制不能替代完整 WAF。
15.5 Nginx 不应承担的安全职责
- 密码哈希;
- 用户会话最终判断;
- 订单权限;
- 数据脱敏;
- SQL 参数化;
- 业务级 CSRF Token;
- 租户隔离;
- 审批流程;
- 密钥管理系统;
- 漏洞修复。
15.6 安全配置不应成为复制粘贴清单
TLS 和安全 Header 都存在上下文:
- 客户端兼容性;
- 域名结构;
- CA 能力;
- 浏览器行为;
- 前端资源;
- 云 LB 链路;
- 合规要求;
- 应用身份模型。
正确做法是基于威胁模型、实验和监控逐步收紧,而不是一次性复制“满分配置”。
十六、面试与复习问题
16.1 十道核心问题
1. HTTPS 与 TLS 是什么关系?
HTTPS 是运行在 TLS 安全通道上的 HTTP。TLS 提供加密、完整性和身份验证,HTTP 仍负责请求方法、Header、状态码和业务语义。
2. 证书公钥和私钥分别做什么?
证书公开携带公钥和域名身份;服务端使用私钥证明自己持有对应密钥。私钥不能发送给客户端。
3. TLS 1.3 中证书私钥是否直接加密全部业务数据?
不是。证书私钥主要用于身份签名,业务数据使用握手派生出的对称会话密钥加密。
4. SNI 的作用是什么?
客户端在 TLS ClientHello 中声明目标域名,使同一 IP/443 上的 Nginx 能在 HTTP 请求解密前选择正确证书和虚拟主机。
5. ALPN 的作用是什么?
在 TLS 握手中协商上层协议,例如 HTTP/2 的 h2 或 http/1.1。
6. 为什么要使用 fullchain.pem?
它包含叶子证书和必要中间证书,帮助客户端构建到受信任根的完整证书链。
7. HSTS 为什么只能在 HTTPS 响应中设置?
如果 HTTP 响应中的 HSTS 被接受,中间人可以伪造或删除策略。浏览器只信任通过安全连接获得的 HSTS。
8. HTTP-01 与 DNS-01 的主要区别?
HTTP-01 通过 80 端口文件证明控制权,不能签发通配符;DNS-01 通过 TXT 记录验证,可签发通配符,但需要安全的 DNS API 自动化。
9. CORS 是否是鉴权?
不是。CORS 主要控制浏览器脚本读取跨域响应,curl 和后端调用不受同样限制。服务端仍需认证和授权。
10. 为什么 HTTPS 正常仍可能返回 502?
TLS 只覆盖客户端到 Nginx。握手成功后,Nginx 到后端仍可能 DNS 失败、连接拒绝、证书验证失败或超时。
16.2 五道场景分析题
场景 1:云 LB 终止 TLS,Nginx 不断跳转 HTTPS
原因:Nginx 看到内部 HTTP,未识别原始协议。应只信任云 LB 的 X-Forwarded-Proto,并调整跳转逻辑。
场景 2:某些客户端报证书错误,Chrome 正常
可能是中间证书链不完整。Chrome 可能通过缓存补链,Java 或旧设备无法补链。检查 fullchain.pem 和 openssl s_client -showcerts。
场景 3:续期脚本成功,但线上证书仍旧
确认 TLS 是否在 CDN/云 LB 终止;检查 Nginx 实际证书路径、reload 结果、多节点同步和线上证书序列号。
场景 4:前端带 Cookie 跨域,配置 Allow-Origin: *
浏览器会拒绝。必须返回明确 Origin、Allow-Credentials: true 和 Vary: Origin,并处理 CSRF。
场景 5:内部 HTTPS 后端使用自签名证书,能否关闭验证?
不应。应建立内部 CA、配置 proxy_ssl_trusted_certificate、正确 SNI 和主机名。
16.3 三道配置排错题
排错题 1
server { listen 443 ssl http2;}问题:为什么新版本出现弃用提示?
答案:从 1.25.1 起推荐使用独立的 http2 on;。
排错题 2
ssl_certificate /certs/cert.pem;ssl_certificate_key /certs/other-domain.key;问题:Nginx 为什么无法启动?
答案:证书公钥与私钥不匹配。
排错题 3
add_header Access-Control-Allow-Origin "*" always;add_header Access-Control-Allow-Credentials "true" always;问题:为什么浏览器仍提示 CORS?
答案:凭证请求不能使用通配符 Origin。
十七、本文总结
本文完成了 Nginx 从“流量入口”到“安全入口”的升级:
- HTTP 明文无法提供机密性、完整性和可靠身份验证;
- TLS 使用证书和签名验证服务端身份,使用 ECDHE 协商秘密,再使用对称 AEAD 加密业务数据;
- TLS 1.3 中证书私钥主要用于身份签名,不是直接加密全部 HTTP 内容;
- Nginx 在解密 HTTP 前先根据 SNI 选择证书,并通过 ALPN 协商 HTTP/2;
fullchain.pem应包含叶子证书和中间链,私钥必须最小权限保护;- 新配置应使用
http2 on;,并默认仅允许 TLS 1.2/1.3; - Session Cache、Session Ticket 和 0-RTT 都有性能与安全权衡;
- OCSP Stapling 依赖 CA 能力,当前 Let’s Encrypt 已停止 OCSP 服务;
- HTTP 重定向解决当前请求,HSTS 影响未来请求,
includeSubDomains和preload必须谨慎; - ACME 自动化必须包括挑战验证、持久化、
nginx -t、reload、远程验证和到期告警; - Nginx ACME 模块是可选模块,不能假设所有安装默认具备;
- TLS 可以在 CDN、云 LB、Nginx 或应用终止,内部链路是否再加密取决于信任边界;
- 安全 Header 需要区分入口统一策略和应用动态策略;
- CORS 是浏览器访问控制,不是服务端鉴权;
- HTTPS 故障应按 DNS、TCP、TLS、虚拟主机、HTTP、Header、upstream 和续期链路逐层排查。
下一篇将进入缓存、压缩与静态资源优化,继续分析浏览器缓存、Nginx 静态资源、proxy_cache、gzip/Brotli 和 CDN 如何协作。
参考资料
以下资料均于 2026-07-07 访问:
- Nginx 官方下载页:Mainline 1.31.2、Stable 1.30.3
https://nginx.org/en/download.html - Nginx 官方文档:Module
ngx_http_ssl_module
https://nginx.org/en/docs/http/ngx_http_ssl_module.html - Nginx 官方文档:Configuring HTTPS servers
https://nginx.org/en/docs/http/configuring_https_servers.html - Nginx 官方文档:Module
ngx_http_v2_module
https://nginx.org/en/docs/http/ngx_http_v2_module.html - Nginx 官方文档:Module
ngx_http_v3_module
https://nginx.org/en/docs/http/ngx_http_v3_module.html - Nginx 官方文档:Support for QUIC and HTTP/3
https://nginx.org/en/docs/quic.html - Nginx 官方文档:Module
ngx_http_acme_module
https://nginx.org/en/docs/http/ngx_http_acme_module.html - Nginx 官方文档:Module
ngx_http_proxy_module
https://nginx.org/en/docs/http/ngx_http_proxy_module.html - Let’s Encrypt 官方文档:Challenge Types
https://letsencrypt.org/docs/challenge-types/ - Let’s Encrypt 官方文档:Keep Port 80 Open
https://letsencrypt.org/docs/allow-port-80/ - Let’s Encrypt 官方公告:OCSP Service Has Reached End of Life
https://letsencrypt.org/2025/08/06/ocsp-service-has-reached-end-of-life - Certbot 官方网站与使用说明
https://certbot.eff.org/ - MDN:Strict-Transport-Security
https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security - MDN:Content Security Policy
https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP - MDN:Cross-Origin Resource Sharing
https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS - MDN:Access-Control-Allow-Origin
https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin - MDN:X-Content-Type-Options
https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Content-Type-Options - MDN:Referrer-Policy
https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy - MDN:Permissions-Policy
https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Permissions-Policy - Spring Boot 官方项目页
https://spring.io/projects/spring-boot
文章验收清单
内容完整性
- 解释 HTTP 风险与 TLS 安全属性;
- 讲清 TLS 1.3 握手;
- 讲清 ECDHE、签名和会话密钥;
- 讲清证书链、SAN 和私钥;
- 覆盖 Nginx HTTPS、SNI、ALPN 和 HTTP/2;
- 覆盖 Session Cache、Ticket 和 0-RTT;
- 覆盖 HTTP 跳转、HSTS 和 preload;
- 覆盖 ACME、Certbot 和自动续期;
- 覆盖 TLS 终止、再加密和 mTLS;
- 覆盖安全 Header 与职责边界;
- 覆盖 Host、请求体和基础入口防护;
- 覆盖 CORS、预检和凭证;
- 提供 Docker Compose + Spring Boot 完整实验;
- 提供反例、排查流程和面试题。
技术准确性
- 区分 TLS 1.2 和 TLS 1.3 配置边界;
- 未使用 TLS 1.0/1.1 作为生产基线;
- 未使用旧版
listen ... http2作为推荐写法; - 未把 HTTPS 描述为完整应用安全;
- 未把证书私钥描述为直接加密全部数据;
- 未把根证书要求服务端固定发送;
- 标注 HTTP/3 实验性和构建边界;
- 标注 Nginx ACME 为可选模块;
- 标注 Let’s Encrypt 已停止 OCSP;
- 未把 CORS 描述为鉴权;
- 未使用通配符 Origin 搭配 Credentials;
- 未通过关闭后端证书验证解决问题。
配置可运行性
- Docker 镜像固定为 Nginx 1.30.3;
- 提供完整目录结构;
- 提供本地 CA 和 SAN 证书脚本;
- 提供 Nginx 主配置和 snippets;
- 提供 Spring Boot 后端代码;
- 提供 Dockerfile 与 Compose;
- 提供启动、验证、日志和清理命令;
- 提供
nginx -t; - 提供
openssl s_client和curl --cacert验证; - 所有插图以独立 PNG 文件交付,不嵌入 Markdown。