一、问题的起点:HSTS 改变了开发调试的规则
平时在本地用自签名证书时,浏览器会弹出一个红色警告页,我们习惯性地点一下"继续前往(不安全)"就进去了。这种"凑合能用"的状态,在启用 HSTS(HTTP Strict Transport Security,HTTP 严格传输安全)之后会彻底失效。
原因在于 HSTS 的语义本身:它告诉浏览器"这个域名只能通过安全连接访问,不接受任何不安全的回退"。因此一旦某域名进入 HSTS 生效状态,浏览器会移除证书错误页上的"继续访问"按钮 ,连接直接以 NET::ERR_CERT_AUTHORITY_INVALID 之类的硬错误终止,没有任何手动绕过的余地。
这意味着,在 HSTS 场景下,开发环境的目标不再是"生成一张能勉强用的自签名证书",而是要满足一个更高的标准:一张被操作系统和浏览器真正信任、并且覆盖你所有自定义域名与子域名的证书。本文就围绕这个目标,从原理到落地完整展开。
二、先厘清两个常被混为一谈的问题
很多人在配置时把两件事搅在一起,导致排错时方向全错。它们其实是相互独立的两个维度:
- 问题 A------证书是否"覆盖"这个域名 :证书里有没有声明它能为
auth.myapp.test这个名字背书。这由证书的 SAN(Subject Alternative Name,主题备用名称)决定。 - 问题 B------域名是否"解析"到本机 :浏览器输入
auth.myapp.test后,这个名字能不能被解析到127.0.0.1。这由 hosts 文件或本地 DNS(Domain Name System,域名系统)决定。
两者必须同时成立,缺一不可:
域名能否解析到本机?
否 是
┌──────────────────┬──────────────────┐
否 │ 既连不上 │ 连得上, 但证书 │
证书是否 │ 也不受信任 │ 报错(名称不匹配) │
覆盖该域名? ───┼──────────────────┼──────────────────┤
是 │ 证书没问题, 但 │ ✓ 正常工作 │
│ 根本访问不到 │ │
└──────────────────┴──────────────────┘
记住这张图:本文第三到六节解决"问题 A",第七节解决"问题 B"。
三、HSTS 之下,证书必须满足的三个硬性条件
现代浏览器(Chrome、Edge、Firefox 等)对一张可信的 TLS(Transport Layer Security,传输层安全)证书有三个不可妥协的要求。HSTS 只是让"不满足时的后果"从"可绕过的警告"变成"不可绕过的失败"。
条件一:SAN 必须包含你访问用的每一个域名。 自 Chrome 58(2017 年)起,浏览器完全忽略 CN(Common Name,通用名称),只读取 SAN 来判断证书是否对应当前域名。换句话说,把域名写在 CN 里毫无意义,必须写进 SAN。
SAN 里使用通配符时,有一组容易踩坑的规则,它直接关系到你 HSTS 的 includeSubDomains 能否真正落地:
| 通配符写法 | 能覆盖 | 不能覆盖 |
|---|---|---|
*.myapp.test |
auth.myapp.test、api.myapp.test(仅一级子域) |
顶级域 myapp.test 本身;多级子域 a.b.myapp.test |
由此得出两条实践准则:顶级域要单独列入 SAN (通配符不覆盖它),并且通配符只管一级 。如果你启用了 HSTS 的 includeSubDomains,浏览器会强制所有子域走 HTTPS,那么你实际会访问到的每一个子域,SAN 都必须能匹配,否则该子域将直接无法打开。
一个稳妥的 SAN 清单示例:
DNS: myapp.test ← 顶级域, 必须单列
DNS: *.myapp.test ← 覆盖一级子域
DNS: auth.myapp.test ← 关键子域显式列出, 不依赖通配符
DNS: localhost
IP : 127.0.0.1
条件二:证书必须带有 serverAuth 的 EKU。 EKU(Extended Key Usage,扩展密钥用法)声明了证书的用途。用于 HTTPS 服务端的证书,必须包含 "Server Authentication" 这一项,否则即使被信任也会被浏览器拒绝。
条件三:证书(或其签发链)必须在系统/浏览器的信任库中。 这是 HSTS 场景下最关键、也最容易被忽视的一条。没有 HSTS 时,不受信任只是弹个警告;有了 HSTS,不受信任就是硬性失败,毫无退路。
下面这张图把三个条件串成了浏览器实际的校验流程,也直观展示了 HSTS 如何抽掉了最后的安全网:
#mermaid-svg-OEBN1aJaWwrjrWmN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OEBN1aJaWwrjrWmN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OEBN1aJaWwrjrWmN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OEBN1aJaWwrjrWmN .error-icon{fill:#552222;}#mermaid-svg-OEBN1aJaWwrjrWmN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OEBN1aJaWwrjrWmN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OEBN1aJaWwrjrWmN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OEBN1aJaWwrjrWmN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OEBN1aJaWwrjrWmN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OEBN1aJaWwrjrWmN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OEBN1aJaWwrjrWmN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OEBN1aJaWwrjrWmN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OEBN1aJaWwrjrWmN .marker.cross{stroke:#333333;}#mermaid-svg-OEBN1aJaWwrjrWmN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OEBN1aJaWwrjrWmN p{margin:0;}#mermaid-svg-OEBN1aJaWwrjrWmN .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-OEBN1aJaWwrjrWmN .cluster-label text{fill:#333;}#mermaid-svg-OEBN1aJaWwrjrWmN .cluster-label span{color:#333;}#mermaid-svg-OEBN1aJaWwrjrWmN .cluster-label span p{background-color:transparent;}#mermaid-svg-OEBN1aJaWwrjrWmN .label text,#mermaid-svg-OEBN1aJaWwrjrWmN span{fill:#333;color:#333;}#mermaid-svg-OEBN1aJaWwrjrWmN .node rect,#mermaid-svg-OEBN1aJaWwrjrWmN .node circle,#mermaid-svg-OEBN1aJaWwrjrWmN .node ellipse,#mermaid-svg-OEBN1aJaWwrjrWmN .node polygon,#mermaid-svg-OEBN1aJaWwrjrWmN .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OEBN1aJaWwrjrWmN .rough-node .label text,#mermaid-svg-OEBN1aJaWwrjrWmN .node .label text,#mermaid-svg-OEBN1aJaWwrjrWmN .image-shape .label,#mermaid-svg-OEBN1aJaWwrjrWmN .icon-shape .label{text-anchor:middle;}#mermaid-svg-OEBN1aJaWwrjrWmN .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-OEBN1aJaWwrjrWmN .rough-node .label,#mermaid-svg-OEBN1aJaWwrjrWmN .node .label,#mermaid-svg-OEBN1aJaWwrjrWmN .image-shape .label,#mermaid-svg-OEBN1aJaWwrjrWmN .icon-shape .label{text-align:center;}#mermaid-svg-OEBN1aJaWwrjrWmN .node.clickable{cursor:pointer;}#mermaid-svg-OEBN1aJaWwrjrWmN .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-OEBN1aJaWwrjrWmN .arrowheadPath{fill:#333333;}#mermaid-svg-OEBN1aJaWwrjrWmN .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-OEBN1aJaWwrjrWmN .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-OEBN1aJaWwrjrWmN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OEBN1aJaWwrjrWmN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OEBN1aJaWwrjrWmN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OEBN1aJaWwrjrWmN .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-OEBN1aJaWwrjrWmN .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-OEBN1aJaWwrjrWmN .cluster text{fill:#333;}#mermaid-svg-OEBN1aJaWwrjrWmN .cluster span{color:#333;}#mermaid-svg-OEBN1aJaWwrjrWmN div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-OEBN1aJaWwrjrWmN .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OEBN1aJaWwrjrWmN rect.text{fill:none;stroke-width:0;}#mermaid-svg-OEBN1aJaWwrjrWmN .icon-shape,#mermaid-svg-OEBN1aJaWwrjrWmN .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OEBN1aJaWwrjrWmN .icon-shape p,#mermaid-svg-OEBN1aJaWwrjrWmN .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-OEBN1aJaWwrjrWmN .icon-shape .label rect,#mermaid-svg-OEBN1aJaWwrjrWmN .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OEBN1aJaWwrjrWmN .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-OEBN1aJaWwrjrWmN .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-OEBN1aJaWwrjrWmN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 能
不能
匹配
不匹配
否
是
浏览器收到服务器证书
证书链能否追溯到
受信任的根证书?
SAN 是否匹配
当前访问的域名?
证书不受信任
连接安全 (绿色锁)
该域名是否处于
HSTS 生效状态?
显示警告页
可点击'继续访问'
硬性失败
无任何绕过手段
四、动手之前:本地开发顶级域(TLD)的隐藏陷阱
在为开发环境挑选域名(如 myapp.???)时,顶级域 TLD(Top-Level Domain,顶级域名)的选择本身就藏着几个坑,选错会让你在 HSTS 上额外受罪:
| 顶级域 | 是否推荐 | 原因 |
|---|---|---|
.dev、.app |
绝不要用 | 它们是真实存在的公共 TLD,且整个 TLD 已被加入浏览器的 HSTS 预加载列表,浏览器对它们强制 HTTPS。用于本地开发既混淆视听,又徒增麻烦。 |
.local |
不推荐 | 被 RFC 6762 保留给 mDNS(multicast DNS,多播 DNS)。在 macOS(Bonjour)和 Linux(Avahi)上,.local 的解析会走 mDNS,可能与 hosts 条目冲突或造成解析延迟。 |
.localhost |
可用 | 被 RFC 6761 保留。浏览器把 *.localhost(如 app.localhost)自动解析到回环地址,无需改 hosts,且被视为安全上下文。 |
.test |
推荐 | 被 RFC 6761 明确保留用于测试,永远不会成为真实 TLD,不与任何公网域名冲突,适合本地开发。 |
本文统一采用 .test(如 myapp.test、auth.myapp.test)作为示例。这里还有一个值得知道的背景:localhost 之所以平时不用证书也能跑 Service Worker 等特性,是因为浏览器把它当作"潜在可信来源(secure context)"特殊对待;而你的自定义域名不享受这个待遇,这正是必须为它们准备真实可信证书的根本原因。
五、理解信任链:自签名为什么"需要被自己信任"
要理解后面几种生成方式的差异,先要看懂证书的信任链。浏览器信任一张证书,本质是能把它沿着"签发关系"一路追溯到一个已在系统信任库里的根证书。
#mermaid-svg-sAw4VtDgsYTn9EwQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sAw4VtDgsYTn9EwQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sAw4VtDgsYTn9EwQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sAw4VtDgsYTn9EwQ .error-icon{fill:#552222;}#mermaid-svg-sAw4VtDgsYTn9EwQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sAw4VtDgsYTn9EwQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sAw4VtDgsYTn9EwQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sAw4VtDgsYTn9EwQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sAw4VtDgsYTn9EwQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sAw4VtDgsYTn9EwQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sAw4VtDgsYTn9EwQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sAw4VtDgsYTn9EwQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sAw4VtDgsYTn9EwQ .marker.cross{stroke:#333333;}#mermaid-svg-sAw4VtDgsYTn9EwQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sAw4VtDgsYTn9EwQ p{margin:0;}#mermaid-svg-sAw4VtDgsYTn9EwQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-sAw4VtDgsYTn9EwQ .cluster-label text{fill:#333;}#mermaid-svg-sAw4VtDgsYTn9EwQ .cluster-label span{color:#333;}#mermaid-svg-sAw4VtDgsYTn9EwQ .cluster-label span p{background-color:transparent;}#mermaid-svg-sAw4VtDgsYTn9EwQ .label text,#mermaid-svg-sAw4VtDgsYTn9EwQ span{fill:#333;color:#333;}#mermaid-svg-sAw4VtDgsYTn9EwQ .node rect,#mermaid-svg-sAw4VtDgsYTn9EwQ .node circle,#mermaid-svg-sAw4VtDgsYTn9EwQ .node ellipse,#mermaid-svg-sAw4VtDgsYTn9EwQ .node polygon,#mermaid-svg-sAw4VtDgsYTn9EwQ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-sAw4VtDgsYTn9EwQ .rough-node .label text,#mermaid-svg-sAw4VtDgsYTn9EwQ .node .label text,#mermaid-svg-sAw4VtDgsYTn9EwQ .image-shape .label,#mermaid-svg-sAw4VtDgsYTn9EwQ .icon-shape .label{text-anchor:middle;}#mermaid-svg-sAw4VtDgsYTn9EwQ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-sAw4VtDgsYTn9EwQ .rough-node .label,#mermaid-svg-sAw4VtDgsYTn9EwQ .node .label,#mermaid-svg-sAw4VtDgsYTn9EwQ .image-shape .label,#mermaid-svg-sAw4VtDgsYTn9EwQ .icon-shape .label{text-align:center;}#mermaid-svg-sAw4VtDgsYTn9EwQ .node.clickable{cursor:pointer;}#mermaid-svg-sAw4VtDgsYTn9EwQ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-sAw4VtDgsYTn9EwQ .arrowheadPath{fill:#333333;}#mermaid-svg-sAw4VtDgsYTn9EwQ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-sAw4VtDgsYTn9EwQ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-sAw4VtDgsYTn9EwQ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sAw4VtDgsYTn9EwQ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-sAw4VtDgsYTn9EwQ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sAw4VtDgsYTn9EwQ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-sAw4VtDgsYTn9EwQ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-sAw4VtDgsYTn9EwQ .cluster text{fill:#333;}#mermaid-svg-sAw4VtDgsYTn9EwQ .cluster span{color:#333;}#mermaid-svg-sAw4VtDgsYTn9EwQ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-sAw4VtDgsYTn9EwQ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-sAw4VtDgsYTn9EwQ rect.text{fill:none;stroke-width:0;}#mermaid-svg-sAw4VtDgsYTn9EwQ .icon-shape,#mermaid-svg-sAw4VtDgsYTn9EwQ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sAw4VtDgsYTn9EwQ .icon-shape p,#mermaid-svg-sAw4VtDgsYTn9EwQ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-sAw4VtDgsYTn9EwQ .icon-shape .label rect,#mermaid-svg-sAw4VtDgsYTn9EwQ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sAw4VtDgsYTn9EwQ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-sAw4VtDgsYTn9EwQ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-sAw4VtDgsYTn9EwQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 由谁签发
必须存在于
叶证书
(服务器用的那张)
中间/根 CA 证书
系统信任库
(受信任的根证书颁发机构)
这就引出两种思路的根本区别:
- 纯自签名证书(方式 B、C):这张证书"自己签发自己",它既是叶证书也是根。要让它被信任,只能把它本身塞进系统的"受信任的根证书颁发机构"库。简单,但每换一张证书就要重新信任一次。
- 本地 CA 签发(方式 A,mkcert) :先在本机创建一个本地 CA(Certificate Authority,证书颁发机构),把这个 CA 装进信任库一次;此后由它签发的所有叶证书都自动被信任,结构更干净,扩展更方便。
理解了这层关系,下面三种方式的取舍就清晰了。
六、三种生成方式
补充一句:ASP.NET Core 自带的
dotnet dev-certs https工具只能为localhost生成证书,无法添加自定义 SAN,因此不适用于本文场景,需要我们自己生成。
方式 A(强烈推荐):mkcert ------ 自动建立受信任的本地 CA
mkcert 会在本机创建并信任一个本地 CA(同时装入系统信任库和 Firefox 自带的库),之后它签发的所有证书天然受信任。这恰好把 HSTS 最难的"必须被信任"一步自动化了,摩擦最小。
bash
# 安装(Windows 任选其一)
choco install mkcert
# 或 scoop install mkcert
# 创建并信任本地 CA(整台机器只需做一次)
mkcert -install
# 生成覆盖多域名 / 子域名的证书
mkcert myapp.test "*.myapp.test" auth.myapp.test localhost 127.0.0.1 ::1
# 产物类似: myapp.test+5.pem(证书) 与 myapp.test+5-key.pem(私钥)
后续若要新增子域,重新跑一条 mkcert 命令即可,无需任何手动信任操作。
方式 B:PowerShell New-SelfSignedCertificate(Windows 原生,无需安装)
适合不想装额外工具的 Windows 环境。以管理员身份运行 PowerShell:
powershell
# 1) 生成证书: -DnsName 自动填充 SAN, -Type 设置 serverAuth EKU
$cert = New-SelfSignedCertificate `
-Subject "CN=myapp.test" `
-DnsName "myapp.test","*.myapp.test","auth.myapp.test","localhost" `
-KeyAlgorithm RSA -KeyLength 2048 `
-NotAfter (Get-Date).AddYears(2) `
-CertStoreLocation "Cert:\LocalMachine\My" `
-FriendlyName "MyApp Dev Cert" `
-Type SSLServerAuthentication
# 2) 将其加入"受信任的根证书颁发机构"------HSTS 能正常工作的关键一步
$root = New-Object System.Security.Cryptography.X509Certificates.X509Store("Root","LocalMachine")
$root.Open("ReadWrite"); $root.Add($cert); $root.Close()
# 3) 导出 PFX 供 Kestrel 使用
$pwd = ConvertTo-SecureString -String "DevPwd_ChangeMe" -Force -AsPlainText
Export-PfxCertificate -Cert $cert -FilePath ".\myapp-dev.pfx" -Password $pwd
要点:-Type SSLServerAuthentication 自动赋予 serverAuth EKU;-DnsName 自动写入 SAN;自签名证书既是叶也是根,放进 Root 库即完成信任。开发自测足够,但不如 mkcert 的"独立 CA + 叶证书"结构清爽。
方式 C:OpenSSL(跨平台、完全可控)
适合 Linux / macOS 或 CI(Continuous Integration,持续集成)流水线。用 -addext 一条命令同时带上 SAN 与 EKU:
bash
# 1) 生成自签名证书 + 私钥
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout myapp-dev.key -out myapp-dev.crt \
-days 730 -subj "/CN=myapp.test" \
-addext "subjectAltName=DNS:myapp.test,DNS:*.myapp.test,DNS:auth.myapp.test,DNS:localhost,IP:127.0.0.1" \
-addext "extendedKeyUsage=serverAuth"
# 2) 合并为 PFX(Personal Information Exchange,个人信息交换)供 Kestrel 使用
openssl pkcs12 -export -out myapp-dev.pfx \
-inkey myapp-dev.key -in myapp-dev.crt \
-passout pass:DevPwd_ChangeMe
OpenSSL 不会自动信任,需手动导入 myapp-dev.crt:Windows 上双击 → 安装到"本地计算机" → "受信任的根证书颁发机构";macOS 在钥匙串中将其设为"始终信任"。
七、让域名解析到本机:hosts 与本地 DNS
证书备齐后,要解决第二节的"问题 B"------让 myapp.test 这类名字解析到本机。编辑 hosts 文件:
-
Windows:
C:\Windows\System32\drivers\etc\hosts(需管理员) -
Linux / macOS:
/etc/hosts127.0.0.1 myapp.test
127.0.0.1 auth.myapp.test
127.0.0.1 api.myapp.test
一个关键限制:hosts 文件不支持通配符 ,即便证书里有 *.myapp.test,每个子域仍需逐行写入。如果子域很多、希望 *.myapp.test 自动解析,需要本地 DNS 工具(Windows 用 Acrylic DNS Proxy,Linux/macOS 用 dnsmasq)来做通配解析,这属于进阶配置。大多数开发场景把常用几个子域写进 hosts 即可。
顺带一提:若你采用前述的
.localhost方案,app.localhost之类的子域会被浏览器自动解析到回环地址 ,连 hosts 都不用改------这是.localhost相较.test的一个便利之处,代价是它在某些工具链里不如真实风格的域名通用。
八、把证书接入 Kestrel
最直接的方式是在 appsettings.Development.json 里为端点指定证书:
json
{
"Kestrel": {
"Endpoints": {
"Https": {
"Url": "https://0.0.0.0:5001",
"Certificate": {
"Path": "myapp-dev.pfx",
"Password": "DevPwd_ChangeMe"
}
}
}
}
}
更推荐避免硬编码密码 。如果采用方式 B 把证书放进了 LocalMachine\My,可直接按存储区引用,完全不出现密码:
json
"Certificate": {
"Subject": "myapp.test",
"Store": "My",
"Location": "LocalMachine",
"AllowInvalid": false
}
或用代码方式,密码交给 user-secrets 管理:
csharp
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenAnyIP(5001, listen =>
{
listen.UseHttps("myapp-dev.pfx",
builder.Configuration["DevCertPassword"]); // 来自 user-secrets
});
});
mkcert 产出的 PEM(Privacy-Enhanced Mail)文件也能直接用:
csharp
listen.UseHttps(
X509Certificate2.CreateFromPemFile("myapp.test+5.pem", "myapp.test+5-key.pem"));
九、HSTS 专属排错
整条链路里,HSTS 会带来几个它特有的坑,单独列出:
1. 之前失败留下的 HSTS 状态会持续干扰。 如果你早先用未受信任的证书访问过这些域名,浏览器可能已经为它们记下了 HSTS/证书策略。换上新证书后若仍报错,需手动清除:Chrome/Edge 打开 chrome://net-internals/#hsts,在 "Delete domain security policies" 输入域名(如 myapp.test)逐个删除,然后重启浏览器。这一步在调试初期几乎是必做的。
2. 想在开发环境彻底不发 HSTS 头 ,可保留模板自带的环境判断(if (!app.Environment.IsDevelopment()) 才调用 app.UseHsts()),或把域名加入排除列表:
csharp
builder.Services.AddHsts(options =>
{
options.ExcludedHosts.Add("myapp.test");
options.ExcludedHosts.Add("auth.myapp.test");
});
注意:ExcludedHosts 默认只排除 localhost、127.0.0.1、[::1],不含你的自定义域名;而且"不再发头"并不会清除浏览器已经记住的旧策略,旧策略仍需按第 1 点手动清理。
3. 反向代理场景下 HSTS 头可能根本发不出。 app.UseHsts() 只在请求本身是 HTTPS 时才添加 Strict-Transport-Security 头。如果本地前置了 HTTP 反向代理(或 YARP),必须正确配置 ForwardedHeadersMiddleware 识别 X-Forwarded-Proto,否则应用以为请求是 HTTP,会跳过发送 HSTS 头。
十、验证清单
按顺序逐项确认,绝大多数问题都能定位:
[ ] 证书 SAN 是否包含你访问的每个域名/子域(顶级域单列、通配符只管一级)
[ ] 证书是否带 serverAuth 的 EKU
[ ] 证书(或其 CA)是否在"受信任的根证书颁发机构"中 ← HSTS 下最常见的失败点
[ ] 选用的顶级域是否安全(避开 .dev/.app/.local, 优先 .test/.localhost)
[ ] hosts 是否把每个子域都指向 127.0.0.1(hosts 不支持通配符)
[ ] Kestrel 是否正确加载了这张证书(查看启动日志里的监听地址)
[ ] 浏览器是否残留旧的 HSTS/证书策略(chrome://net-internals/#hsts 清理后重启)
[ ] 反向代理场景: 转发头中间件是否配好
十一、小结
启用 HSTS 后,开发环境的证书工作可以浓缩为一个心智模型:HSTS 抽掉了"点击继续"这张安全网,于是自签名证书必须从"能凑合"升级为"被真正信任、且覆盖所有目标域名"。
落地时把握三件事就不会乱:其一,分清"证书覆盖域名(SAN)"与"域名解析到本机(hosts)"是两个独立问题,二者缺一不可;其二,证书必须同时满足 SAN 匹配、serverAuth EKU、进入信任库三个硬条件,而信任库是 HSTS 下最容易翻车的一环;其三,域名本身的选择有讲究,避开 .dev/.app/.local,优先 .test 或 .localhost。
工具层面的建议很明确:开发环境首选 mkcert ,它把"建立受信任 CA → 签发含 SAN 的证书 → 装入信任库"一次性解决,与 HSTS"必须被信任"的要求天然契合,新增子域只需重跑一条命令;若不便安装工具,Windows 上 New-SelfSignedCertificate 配合导入 Root 库同样完全够用。