在 ASP.NET Core 开发环境中为自定义域名签发受信任的自签名证书—HSTS 启用后的完整实践

一、问题的起点: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.testapi.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.testauth.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/hosts

    127.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 默认只排除 localhost127.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 库同样完全够用。

相关推荐
无风听海1 小时前
深入理解 ASP.NET Core 中的UseHsts()
后端·asp.net
学编程的小程1 小时前
DISTINCT 的“惯性陷阱“:当去重操作沦为性能累赘
后端
Ztopcloud极拓云视角2 小时前
我用AI辅助做了一个多端工具:解决2026世界杯回放被剧透的问题
人工智能·windows·个人开发
雪宫街道2 小时前
SpringBoot 向 IOC 容器注册组件的两种姿势:@Configuration 与 @Import
java·spring boot·后端·spring
techdashen2 小时前
Cargo 1.94 开发周期全解析
开发语言·后端·rust
love530love2 小时前
2026年终极防坑指南:基于 EPGF 架构彻底“本地化” UV 环境与工具
人工智能·windows·python·架构·devops·uv·epgf
枕星而眠2 小时前
Linux守护进程完全指南:从原理到实战
linux·运维·服务器·c++·后端
虾壳云官方2 小时前
【本地 AI 自动化最新工具】 OpenClaw 2.7.9 Windows 完整部署教程(包含安装包)
人工智能·windows·openclaw·openclaw安装·openclaw一键部署
lzjava20242 小时前
Python的数据结构,推导式、迭代器和生成器
数据结构·windows·python