资讯动态

Nginx 配置 HSTS:强制 HTTPS 访问的原理与实战避坑指南

发布时间:2026/10/17 1:47:19 来源:尧图企业网站定制
很多人觉得自己折腾完 Nginx 证书配置、https 能正常访问就大功告成了但真正上线跑业务之后才发现http 明文访问这个口子一直没堵上。新用户手动敲个域名进来默认就是 http跳转做得再好看也会闪一下甚至有些场景下直接把 POST 请求给丢了。我在实际项目里折腾过一轮之后只推荐一个方案Nginx 开启 HSTS让浏览器强制跳转 HTTPS 访问。HSTS 全称 HTTP Strict Transport Security简单说就是服务器告诉浏览器“接下来一段时间你只准用 https 来找我”。这一步做完浏览器层面就直接帮你把 http 请求改写成 https根本不给明文传输留机会。这篇文章不讲虚的直接把 HSTS 的原理、Nginx 配置方法、验证手段以及我踩过的坑全部整理出来适合手里已经配好证书但还没管过强制跳转的开发者也适合准备做安全加固的运维同学。1. 为什么普通的 301 跳转还不够1.1 手动输入域名和旧链接造成的访问缝隙先说一个最简单但经常被忽略的事实浏览器默认对用户输入的域名使用的就是 http:// 或当前页面的协议除非用户明确输入 https://。你的服务端把“http://xxx 跳转到 https://xxx”配得再好用户第一次访问的时候那个 http 请求已经发出去了。这一步的时间窗口虽然短到几百毫秒但在公共 Wi-Fi、DNS 被污染、运营商注入广告这些环境下这短短的一次明文请求就足够出问题。更不用提有些用户把站点加入书签时收集的还是 http 链接这些链接触发不了你的 Nginx 重定向逻辑还好如果重定向没配置好就一直停留在不安全页面上。我见过一个电商类模拟项目上线之后运维发现结算页偶尔会出现“页面失效请重新下单”的提示。查了半天才发现有部分用户是从旧 APP 里的 http 链接进的商城首页虽然服务器把首页重定向到 https但用户一旦在 http 状态下点击商品列表页那个请求就是普通 http 的 GET一旦进入购物车数据读写就出现部分数据丢失。后来排查定位到是“http 跳 https 之前页面资源已经加载了一部分”跟 HSTS 没有任何关系但也让我意识到重定向只能解决“响应层面”的问题解决不了“浏览器发出请求之前”的习惯问题。1.2 HSTS 的响应头机制和浏览器行为HSTS 的响应头长成这个样子的Strict-Transport-Security: max-age31536000; includeSubDomains; preload浏览器第一次从你的服务器收到带这个响应头的 HTTPS 回应时会在内存和磁盘缓存里记下一条规则这个域名在 max-age 指定的秒数内只能通过 HTTPS 访问。一旦这条规则存在用户之后输入 http:// 的地址或者点击 http:// 的旧链接浏览器会直接在地址栏把协议改写成 https然后才发起网络请求。也就是说整个改写动作发生在浏览器内部请求根本不会以明文形式发出去。理解了这个机制就明白我前面说的“脸上笑嘻嘻心里妈卖批”的 301 跳转到底差在哪里301 是请求到了服务器再弹回去HSTS 是浏览器先自断后路。这里还有一个容易混淆的点HSTS 响应头只能通过 HTTPS 响应下发。如果你访问的是 http://服务器返回的即使带了 Strict-Transport-Security 头浏览器也会丢弃不理。这一点对配置排障特别关键我后面还会专门说。1.3 什么时候非用 HSTS 不可也不是所有站点都必须立刻上 HSTS但符合下面任意一条我的建议是尽量早开启站点涉及登录、支付、个人中心等任何敏感数据操作。站点是面向公众的官网担心被仿冒或者内容被运营商篡改。你已经在搜索引擎提交了 https 版本不希望 http 版本被收录。你正在做安全合规相关的工作有明确的“传输通道必须加密”要求。反过来讲如果你只是做一个临时 Demo域名后面还不确定会不会换或者子域名上挂了很多不同证书的服务可以先不启用 includeSubDomains只设置一个短一点的 max-age我待会儿在配置部分会写清楚怎么控制这个风险。2. 配置 HSTS 之前的检查清单2.1 确认 HTTPS 访问完全正常再动手HSTS 有个很大的特点一旦浏览器记住了规则在生效期内你想回退到 http 都没门。所以配置之前必须确认三件事证书对所有需要访问的域名都有效包括裸域名和 www 子域名。没有混合内容漏洞页面引用的 JS、CSS、图片、XHR 请求全部是 https。HTTP 跳转 HTTPS 已经稳定运行没有出现死循环或者跳错域名的现象。检查混合内容这一条很多人容易漏。浏览器一旦对整站启用 https页面里任何一个 http 资源都会被拦截那时候页面样式丢失、功能按钮失灵都很常见。我在一个实际项目中就处理过客服系统图标不显示的问题排查到最后是头像地址还在用 http 协议。2.2 确认 Nginx 版本和模块HSTS 配置本身不依赖额外模块只要是 Nginx 都支持 add_header 指令。唯一要注意的是你用的 Nginx 版本不能太老建议至少 1.10 以上的版本因为新版对 add_header 的继承逻辑处理得更直观写起来更安全。可以用以下命令查看nginx -v如果公司环境里用的是 OpenResty 或者 Tengine 这类分支版本add_header 指令本身也是可用的放心。2.3 规划 max-age 和安全回退策略max-age 是 HSTS 最核心的参数单位是秒。它的含义是“浏览器记住这条规则多长时间”。我最初上线的时候是先用 60 秒测试验证响应头能正常下发。观察到没问题之后会把 max-age 调整成 6 小时21600 秒观察几天。再没有异常再把 max-age 提升到 1 年31536000 秒。这个渐进式的过程不是官方要求而是我从实际排障中学到的稳妥路线。因为一旦 HSTS 缓存生效你要改回 http 或者换证书时用户浏览器仍然会强制 https 请求证书如果配置不当用户直接看到安全警告。如果你不确定可以一开始不加 includeSubDomains 和 preload只设置一个中等长度的 max-age比如Strict-Transport-Security: max-age86400这个意思是“本域名一天之内只能走 https”子域名不强制也不参与预加载列表。即使出了问题一天后浏览器缓存自动消失回退代价就小很多。3. Nginx 实操配置 HSTS 的完整过程3.1 最简单粗暴的配置方法在 Nginx 的 server 块里只要 HTTPS 那个 server 配置存在就可以直接加上一行server { listen 443 ssl; server_name www.example.com example.com; ssl_certificate /usr/local/nginx/ssl/example.com.pem; ssl_certificate_key /usr/local/nginx/ssl/example.com.key; add_header Strict-Transport-Security max-age31536000; includeSubDomains always; }重点是最后一行add_header 结尾的 always 表示无论返回什么状态码200、301、404 等都带上这个响应头。如果不写 always某些状态码下响应头不会出现这会影响浏览器缓存 HSTS 规则。同时原来那个监听 80 端口的 server 继续保留继续做 301 跳转。因为即使有 HSTS浏览器第一次访问时还没有缓存规则仍然可能发 http 请求这个跳转是保底方案server { listen 80; server_name www.example.com example.com; return 301 https://$host$request_uri; }这里有个细节很多人问过我HSTS 配置了之后http 的 301 跳转还需要保留吗答案是需要而且必须保留。HSTS 不是替代 301 跳转而是从浏览器内部阻止第一次不知不觉的明文访问。两者各司其职。3.2 多域名和多 server 块时的小心机如果你一台服务器上跑了多个站点每个域名都配置了独立的 443 server那么 HSTS 头需要加到每个 HTTPS server 块里因为 add_header 指令不会跨 server 继承。例子里如果有接口域名 api.example.com 和主站 www.example.com我给两个站点分别配了 HSTS 头。这种情况下建议把主站的 includeSubDomains 打开这样 api 子域名也会受到保护同时接口站自己那行 HSTS 也可以配双保险不会产生冲突。还有一个小技巧如果你的 Nginx 配置里有很多 server 块不想重复粘贴可以把 HSTS 配置写在 conf.d 下的一个专门文件里然后 include。比如# /usr/local/nginx/conf/hsts.conf add_header Strict-Transport-Security max-age31536000; includeSubDomains always;不过 Nginx 的 include 对 add_header 的处理稍微有点坑需要确认 include 的位置是在 server 块内部因为 add_header 属于 http、server、location 上下文里的指令放在 http 块里 include 的话还是所有 server 生效。我个人实践下来还是老老实实复制到每个 server 块最直接因为 Nginx 里的 add_header 有继承覆盖逻辑比如某个 location 里自己也写了 add_header那个 location 就会忽略上一级 server 里定义的 HSTS 头。这个问题我下一节单独说。3.3 配置说明add_header 的继承覆盖坑这是我在线上遇到过的典型问题某接口 server 块里加了 HSTS 头但死活不生效F12 看到的响应头里就是没有 Strict-Transport-Security。想了半天才想起来这个 server 里某个 location 下配置了 CORS 相关的 add_header比如location /api/ { add_header Access-Control-Allow-Origin *; }因为该 location 里出现了 add_header 指令Nginx 的规则是如果当前层级location有 add_header就只使用当前层级的 add_header不再继承上一级server的 add_header。也就是 server 里的 HSTS 头静默失效了。解决办法有两个每个 location 里手动把 HSTS 头再写一遍。把跨域头统一写在 server 块不在 location 里写 add_header。我现在一般推荐第二种方案因为统一写在 server 块里能最大程度避免遗漏。不过要注意Nginx 不同版本的 add_header 继承细节有差异升级 Nginx 或者换 OpenResty 之后最好重新 curl 验证一下别想当然。4. 验证 HSTS 是否生效的方法4.1 用 curl 检查响应头配置完 Nginxreload 之后不要急着开浏览器先用 curl 直接看返回头。curl -I https://www.example.com返回内容里应该能看到Strict-Transport-Security: max-age31536000; includeSubDomains这里要注意的是必须访问 https不能用 http 来验证 HSTS 头是否下发正常。因为前面提过HTTP 响应里的 HSTS 头浏览器会忽略而且部分服务器在 301 响应里确实会带但那种情况不说明任何问题。可以再用一个参数看完整响应是否正常curl -I --max-time 5 http://www.example.com这时候你会看到 301 跳转直接到 https 地址这是正常现象说明你的 http 到 https 兜底逻辑还在工作。另外一个更严格的验证方式用 openssl 模拟一下openssl s_client -connect www.example.com:443 -servername www.example.com然后手动输入 HEAD / HTTP/1.1Host: www.example.com也可以看到响应头不过日常排障用 curl 就够了。4.2 浏览器地址栏的实测表现用 Chrome 或者 Edge 访问一次 https://www.example.com确认响应里有 HSTS 头之后在地址栏手动输入http://www.example.com浏览器会直接改写为https://www.example.com地址栏不会出现加载 http 的中间过程。如果没反应最常见的两个原因HSTS 响应头没有正常下发用 curl 查一下。浏览器对 http:// 的输入建了单独的访问缓存需要彻底清除该站点的数据再试。Chrome 里可以在地址栏输入chrome://net-internals/#hsts查看当前浏览器记住的 HSTS 规则也可以在 “Delete domain security policies” 那里输入域名删除测试规则非常方便。这个面板还能帮你查某个域名是不是已经在 preload 列表里、查询当前的安全状态属于我平时最爱用的调试工具没有之一。做浏览器强制跳转调试之前先到这里面看看到底是浏览器没记住还是在重新请求。4.3 在线工具和头检测如果不想记命令也可以用一些在线的响应头检测工具输入 https 地址看返回的响应头里是否包含 Strict-Transport-Security。这类工具对于不熟悉命令行的运维和返工排查比较友好。不过注意在线工具检测的是服务器末尾的响应而且要确保你自己能访问到同一个 Edge 节点。如果你在用 CDNCDN 可能自己会剥离或者改写响应头那么检测出来的结果不一定是源站的真实情况需要结合源站 IP 排查。5. 部署完 HSTS 之后的高频问题排查5.1 为什么设置了 HSTS浏览器还是能通过 http 访问这是群里被问得最多的一个问题。绝大多数情况是因为第一次访问时浏览器并没有成功接收到带 HSTS 头的 HTTPS 响应。我建议按照下面的顺序排查curl -I https://域名 确认响应头包含 Strict-Transport-Security。确认访问的域名和配置 HSTS 的域名完全一致包括是否带 www。清除浏览器该站点的缓存和安全数据确保没有旧的 http 记录。确认请求没有被代理、防火墙、CDN 拦截并重写。排障的时候有个技巧打开浏览器的隐身模式因为隐身模式一般不继承之前的 HSTS 策略直接输入 http:// 域名观察地址栏行为如果隐身模式下会自动改写为 https说明你前面的配置是生效的是原来的浏览器缓存了旧状态。如果隐身模式也不改写那就继续排查服务器配置或中间环节。5.2 max-age 设置的太短的坑刚开始测试的时候我会把 max-age 设成 60 秒好用是好用但问题在于用户浏览器第一次访问后只要过了 60 秒规则就失效了。用户如果刷一下手机再访问一次 http 链接又会重新走一次明文请求而且页面上因为已经发起了 http 请求浏览器不会自动改写。这就完全失去了 HSTS 的意义。所以我的建议是测试期可以短正式上线至少 864001 天起步稳定之后 315360001 年。还有一种情况是你原本设置过 60 秒的 HSTS过了两个小时后改成 31536000但用户的浏览器还是会按照 60 秒过期。因为只要浏览器缓存里有更短的 max-age新的 HSTS 头要等浏览器更新到同一条规则时才会覆盖。这个不像 Cookie 可以立即清空只能等原来的 max-age 到期。这也是为什么我建议从测试一开始就要有长期规划不要随便开 HSTS 又随便改。5.3 要不要上 preloadpreload 是 HSTS 的加强版。把域名提交到浏览器内置的 HSTS 预加载列表之后即使用户从未访问过你的网站浏览器也知道这个域名只能走 https彻底避免“第一次访问风险”。听起来很完美但我要给它泼一盆冷水提交 preload 很难撤销。浏览器供应商明确说明从预加载列表删除域名需要较为漫长的流程并且不是立即生效你需要持续保证 https 服务可用。如果哪天你的站点要换域名、降级 http、或者某个子域名准备单独上 http 服务preload 就是给自己找麻烦。如果想上 preload把 preload 加到 HSTS 头里之后去浏览器官方维护的提交站点提交你的域名。提交前检查这三条硬性要求有有效的 HTTPS 证书。所有 HTTP 请求 301 或 308 跳转到 HTTPS。HSTS 头里包含 preload 指令且 max-age 至少 31536000。我用过的模拟项目都是稳妥线路先不加 preload确认半年内没有回退需求再加入。5.4 includeSubDomains 下不小心影响子域名的坑includeSubDomains 会把 HSTS 规则覆盖到所有子域名。这本身是个好事但如果你子域名下有一台设备或者内部系统仍然在用 http 方式访问比如打印机后台、老旧的摄像头管理页、某个没有 https 证书的管理端includeSubDomains 直接把他们的 http 访问路径给禁了。我处理过的一个场景公司内部的 ERP 系统开启了 includeSubDomains结果部分分公司员工访问某个子系统那个子域名还有 http 端口一直被拦截排查了很久才意识到是这个原因。如果不想误伤建议对于主站域名没必要一开始就带 includeSubDomains可以把主站 HSTS 头和子域名的服务分开配置子域名逐个加固。如果确定子域名全部支持 https再开启 includeSubDomains 不迟。5.5 服务器时间不对导致 HSTS 异常这是个很隐蔽的问题。HSTS 的 max-age 和浏览器存储的规则都依赖系统时间。如果服务器时间或者用户设备时间严重不准确浏览器可能无法正确判断 HSTS 缓存是否过期。我之前遇到一个用户无论怎么访问都显示证书错误和安全警告排查到最后是他手机系统时间被设置成了两年前。这种属于极端个例但在排查 HSTS 问题时需要知道有这种可能性。6. 几个容易上手的进阶玩法6.1 动态区分 HSTS 是否应该下发如果你的服务器上既有 80 的 http 服务又有 443 的 https 服务而且业务上又想让 HSTS 只在 https 正常的时候才下发可以结合 Nginx 的变量server { listen 443 ssl; server_name www.example.com; add_header Strict-Transport-Security max-age31536000; includeSubDomains always; }这种理解其实是一种“只有 https 连接才会进入这个 server 块”的设计本质也是条件区分。如果你想更复杂一点比如针对特殊路径或手机端单独控制可以通过变量判断set $hsts_header ; if ($https on) { set $hsts_header max-age31536000; } add_header Strict-Transport-Security $hsts_header always;我在实际项目中用这个方式给移动端接口和 H5 页面做了不同 HSTS 时效手机上采用较短的 max-age防止换域名过渡期带来不可控的强制跳转。6.2 HSTS 与 CSP upgrade-insecure-requests 双管齐下HSTS 是从浏览器层强制全站 https内容安全策略CSP里的 upgrade-insecure-requests 则是把页面内 http 资源自动升级为 https 请求。这两者可以搭配使用实现两个维度的强制。配合效果更好的是前端页面配置Content-Security-Policy: upgrade-insecure-requests这样即使页面里不小心写死了一个 http 图片地址浏览器也会自动把它升级为 https 加载杜绝混合内容拦截。不过有一点差异升级和拦截机制不同HSTS 是改地址栏upgrade-insecure-requests 是改资源请求两者不冲突互为补充。6.3 配合监控报警发现 HSTS 失效HSTS 配置完毕不是一劳永逸尤其是 Nginx 配置改动、CDN 更新、后端框架加中间件之后响应头很容易被新增逻辑覆盖。我习惯在监控平台里加一条针对响应头的检测规则比如每五分钟请求一次 https 首页检查响应头里是否包含 Strict-Transport-Security。一旦缺失就报警这样能第一时间发现有人把 add_header 写在 location 里导致的覆盖问题。这条监控写起来也不复杂用 Python 的 requests 库简单检查即可import requests r requests.get(https://www.example.com, timeout5) hsts r.headers.get(Strict-Transport-Security, ) if max-age not in hsts: print(HSTS missing)跑在定时任务里比人肉排查可靠得多。7. 一次线上事故给我的复盘去年接手过一个模拟项目此前团队已经配置了 HSTS但一直没完全生效。我在接入日志分析时发现有个接口的响应头偶尔会丢失 Strict-Transport-Security导致部分用户在特定流程下总是绕过 https 直接明文请求。最终定位到的原因有两个第一location 里有独立的 add_header 覆盖了 server 级别的 HSTS 头我在前面已经说了这个坑。第二返回状态码是 301/302 时add_header 需要在指令后面加 always否则部分状态码下 Nginx 不输出自定义响应头。你的 HSTS 头在 301 转跳时丢了浏览器就会认为这个站点并没有明确声明 HSTS 策略。那次之后我给自己定下一个习惯凡是配置 add_header全部老老实实带上 always没有例外。还有一次是 CDN 缓存了不带 HSTS 头的页面导致部分用户的首次访问拿不到正确的响应头。这个问题的根源是我没有在 CDN 层加透传配置。如果你有 CDN 和源站两层建议两层都把 HSTS 头配置出来或者确保 CDN 不剥离源站响应头。另外 CDN 自己后台也有 HSTS 设置可以在 CDN 控制台直接加上但要注意 CDN 边缘节点返回给用户的 HSTS 头是否和源站一致。后来我的配置策略变成了一套固定的三段式Nginx 源站加 add_header、CDN 缓存规则里标注 HSTS 响应头透传、监控告警兜底。三步走下来目前稳定运行再没出过 http 明文访问的问题。8. 写在最后的实战建议HSTS 的配置其实就这么几行难点不在怎么写而在怎么确保它一直在生效。根据我个人经验建议第一次部署时给自己设一个红线先不加 includeSubDomains先把主域名跑满一个月稳定再决定要不要覆盖子域名。期间把监控检测加上尤其是响应头检测防止历史配置死角。另外每次 Nginx reload 或者证书续期之后顺手 curl 看一眼 HSTS 头有没有还在。这个习惯能帮你省掉一大半未来排障的时间。一个小提醒如果你的团队里有前端同学记得让他们也了解 HSTS 的机制。很多时候前端联调时打开本地服务、访问线上接口如果 HSTS 配置生效他们的浏览器会自动把接口请求强制到 https进而出现“明明没有配置 https 却一直请求 https”的困惑。提前跟你团队里的人打招呼省得大家互相怀疑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑