做本地开发的人,多少都被浏览器的“证书无效”页面拦过。最初我面对“本地 HTTPS 证书”这件事,心里就一句话:又不是上生产,为什么要折腾?直到后面连续踩了 PWA 必须跑在安全上下文、第三方登录回调不让用 http、Secure Cookie 在 http 下直接失效、以及同事通过局域网访问我电脑上的服务时浏览器一脸“这地址有问题”这些坑,才明白本地环境同样需要一张能被信任的 HTTPS 证书。这篇文章就来聊聊我自建本地 HTTPS 证书的完整过程:先用 OpenSSL 手工签一遍搞懂原理,再用 mkcert 这种工具把日常开发效率拉满,最后配合一张常见问题排查表帮你避开我趟过的那些坑。
1. 为什么本地开发也躲不开 https 证书
1.1 不是只有生产环境才需要 https
很多人把 HTTPS 当成“上线之前要换的东西”,但开发阶段里,很多需求根本绕不开它。
最典型的是安全上下文。浏览器里的 Service Worker、navigator.geolocation、getUserMedia(摄像头/麦克风权限)、navigator.credentials 这类 API 都要求页面处于安全上下文。虽然大多数浏览器把http://localhost视为安全上下文,但一旦你换到http://192.168.x.x这种局域网地址,或者用自定义域名访问本地服务,这个“豁免”就不一定生效了。你需要 HTTPS 才能继续调试。
另一个高频场景是 OAuth。各种第三方登录平台在配置回调地址时,往往不接受http://明文回跳。就算本地调试模式允许http://localhost,也经常遇到混合内容的问题:页面本身是 HTTPS 的,但请求里某个资源走的是 HTTP,直接给浏览器拦下来。还有 Secure Cookie,带Secure标志的 Cookie 只能通过 HTTPS 连接发送,否则你就得准备一个无法登录的本地环境。
所以,本地生成 HTTPS 证书不是为了“模拟生产环境装样子”,而是为了让登录态、浏览器安全特性、权限 API 和内网互访都能正常工作。这也是我把这篇文章里的流程当成每个前端/后端开发基本功的原因。
1.2 自签名证书与正规 CA 证书到底差在哪
浏览器判断一个 HTTPS 证书是否可信,看的是“证书链能不能追溯到某个受信任的根证书”。正规证书由公开的 CA 签发,这些 CA 的根证书预装在系统里,浏览器一验就通。自签名证书则是你自己生成、自己签名,没有厂商背书,浏览器默认不认。
大多数新手直接跑:
openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 365生成一张证书,放到 Nginx 里,打开浏览器一看,照样报“您的连接不是私密连接”。原因有两个:
一方面,这张证书没有导进系统信任库,浏览器压根不认识签名者。另一方面,单独一张自签名证书经常缺少 SAN(Subject Alternative Name,主题备用名称),而现代浏览器校验主机名已经不再依赖证书里的 CN 字段,必须看 SAN。这就是为什么你明明把证书装好了,访问https://localhost依然提示“此服务器无法证明它是 localhost”。
正确的做法是走一条完整的“本地 CA 体系”:先生成一个本地根证书,把它导入系统“受信任的根证书颁发机构”;再用这个根证书去签发站点证书,并且在站点证书里写好 SAN。这样浏览器拿到站点证书后,顺着链条找到本机里已信任的根证书,校验通过,就不会再闹情绪。这也正是接下来手写流程和 mkcert 工具所做的事情。
1.3 先定方案:手写 openssl 还是 mkcert
两种办法我都在用:
| 方案 | 优点 | 缺点 | 适合 |
|---|---|---|---|
| OpenSSL 手写 | 完全可控、不依赖第三方、能理解证书底层逻辑 | 命令繁琐,容易漏 SAN,信任导入要手动做 | 学习原理、自动化签发、离线环境 |
| mkcert | 一条命令生成并自动信任、默认带 SAN、跨平台 | 需要下载安装,日常工具依赖 | 日常开发、团队协作、快速出证书 |
如果你只是想让本地服务尽快跑起来,直接用 mkcert 没什么心理负担。但如果你是第一次接触这堆证书文件,我强烈建议先跟着下一章用 OpenSSL 手撸一遍,把“根证书、证书签名请求、SAN、信任锚”这些概念真正落到命令里。之后再切回 mkcert,你会很清楚它在你机器上偷偷做了什么,出了问题也知道从哪查。
2. 用 OpenSSL 手工制作一张受本地信任的证书
2.1 证书签发链路:CA、私钥、SAN 的关系
手工签发本地证书,本质上是在履行一个“迷你证书机构”的工作。整条链路是这样的:
根证书(Root CA,私钥只有你自己有)→ 用根证书私钥给站点证书签名 → 站点证书里写好要保护的域名/IP → 系统信任根证书 → 浏览器验证通过。
这里最关键的是“信任根证书”这一步。站点证书本身不重要,重要的是它由谁签名,而浏览器只信任系统里预置的根证书。所以你手工做的第一件事,不是生成站点的证书,而是先造一个根证书,把这个根证书导到系统信任区里。
还有一点必须明确:证书文件里的crt、pem、cer其实没有本质区别,都是 X.509 格式的证书内容,只是后缀名不同。.key是私钥,.csr是证书签发请求。私钥文件一定要当成密码一样保管,不能随便放公开目录。
2.2 第一步:生成本地根证书(CA)
先建一个干净的目录,推荐放在~/certs/dev-ca,方便管理:
mkdir -p ~/certs/dev-ca && cd ~/certs/dev-ca openssl genrsa -out dev-ca.key 2048 openssl req -x509 -new -nodes -key dev-ca.key -sha256 -days 3650 -out dev-ca.crt \ -subj "/CN=Dev Root CA/O=Local Dev" \ -addext "basicConstraints=critical,CA:TRUE" \ -addext "keyUsage=critical,keyCertSign,cRLSign"解释一下这些参数:
genrsa -out dev-ca.key 2048是生成 2048 位的 RSA 私钥。本地开发不需要上到 4096,2048 足够,性能和安全性都合适。-nodes表示私钥不加密,生成之后不需要每次输入密码,开发环境图省事可以这么干;如果是团队里规范一点,建议去掉-nodes,配合启动时输入密码。-days 3650给根证书十年有效期,根证书一般不用频繁更新,导入一次可以撑很久。
-addext是给根证书加扩展属性。basicConstraints=critical,CA:TRUE表示这是一张 CA 证书,keyUsage=critical,keyCertSign,cRLSign表示它的私钥可以给其他证书签名。这两个属性缺了,某些系统即使在信任库里也不会把它当成合法根证书。如果你用的 OpenSSL 版本比较老不支持-addext,可以写一个 ext 配置文件,效果一样。
2.3 第二步:签发站点证书并与主机名匹配
根证书生成后,开始签站点证书。站点证书的私钥和根证书私钥必须分开,不能共用。
先生成站点私钥和签名请求:
openssl genrsa -out local-dev.key 2048 openssl req -new -key local-dev.key -out local-dev.csr \ -subj "/CN=localhost/O=Local Dev"CN=localhost只是写一个方便识别的名称,真正决定浏览器认不认的是下一份配置文件里的 SAN。创建local-dev.ext,内容类似:
subjectAltName=DNS:localhost,IP:127.0.0.1,IP:::1,DNS:dev.local,DNS:*.l.test basicConstraints=CA:FALSE keyUsage=digitalSignature,keyEncipherment extendedKeyUsage=serverAuth如果之后的笔记本电脑 IP 是192.168.3.50,想用手机或者同事电脑直接访问,也要把IP:192.168.3.50写进去。SAN 字段是浏览器匹配地址的唯一标准,漏掉任何一个实际要访问的域名/IP,都会导致访问时报错。
然后用根证书和上一步的签名请求生成最终的证书文件:
openssl x509 -req -in local-dev.csr -CA dev-ca.crt -CAkey dev-ca.key \ -CAcreateserial -out local-dev.crt -days 398 -sha256 -extfile local-dev.ext-CAcreateserial会生成一个序列号文件,用于区分历次签发的证书。有效期我写398天,这是为了避免受当前浏览器对 TLS 证书有效期不得超过 398 天的限制。如果你写3650,Chrome 有可能不接受,白忙一场。
到了这一步,目录里应该有dev-ca.crt、dev-ca.key、local-dev.crt、local-dev.key。建议把私钥权限收紧:
chmod 600 local-dev.key dev-ca.key注意:
dev-ca.key是你本地信任体系的根,它一旦泄露,别人就可以用你的根证书签发任意网站证书,让所有信任你本地根证书的设备都能被冒充。本地开发也要养成这个安全习惯。
2.4 第三步:把证书挂到本地服务上
证书文件本身不生效,还得让本地服务把证书用起来。以 Nginx 为例,在 server 配置里加:
server { listen 443 ssl; server_name localhost; ssl_certificate /home/you/certs/dev-ca/local-dev.crt; ssl_certificate_key /home/you/certs/dev-ca/local-dev.key; # 其他配置... }配置文件改完后执行nginx -t检查语法,然后systemctl reload nginx或直接重启。注意ssl_certificate填的是local-dev.crt,不是dev-ca.crt。很多人图省事把根证书填进去,结果发出去的是根证书,浏览器没法把这个站点和域名对应起来。
如果你用的是 Node.js 本地服务,核心也是一样的:
const https = require('https'); const fs = require('fs'); https.createServer({ key: fs.readFileSync('./local-dev.key'), cert: fs.readFileSync('./local-dev.crt') }, (req, res) => { res.end('hello https'); }).listen(443);其他工具,比如前端开发服务器、Python 的python -m http.server、Go 服务、Java 服务,配置思路都是指定证书路径和私钥路径。通行的做法是:证书路径和公钥文件。同一个路径引用错了,证书链就会断。
2.5 第四步:把 CA 导入系统信任库
站点证书配置好后,浏览器还是不放心,因为你的根证书还没被系统信任。这一步是手工流程里最容易卡壳的地方。
macOS 上,我一般不开图形界面,用命令行更稳:
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ~/certs/dev-ca/dev-ca.crtWindows 上,管理员权限运行 PowerShell:
certutil -addstore Root dev-ca.crtLinux 的 Debian/Ubuntu 系可以拷贝到系统证书目录后更新:
sudo cp dev-ca.crt /usr/local/share/ca-certificates/dev-ca.crt sudo update-ca-certificates需要特别注意的是,Firefox 默认不完全跟随系统信任库,它自己有独立的证书库。如果测试环境是 Firefox,在“设置 → 隐私与安全 → 证书 → 查看证书 → 证书颁发机构 → 导入”里手动选择dev-ca.crt,勾选“信任此 CA 来标识网站”。这个和 Chrome 的处理不一样,别漏了。
移动端调试还有额外的一层:Android 需要把 CA 证书放到“设置 → 安全 → 加密与凭据 → 安装证书”;iOS 安装描述文件之后,还要在“通用 → 关于本机 → 证书信任设置”里把根证书开关点亮。否则就算安装成功,iOS 默认也不信任。
3. 用 mkcert 把证书生成和信任导入一次搞定
3.1 mkcert 替我们做了什么
如果你的目标只是“赶紧把 HTTPS 跑起来”,手工 OpenSSL 流程确实有点烦。mkcert 这个命令行工具本质上就是把上一章做的事情打包成一条命令:它会在本地生成一个根 CA,然后自动把根证书注册到系统信任库,再用这个根证书签发你指定的域名/IP。你不用再记 openssl 扩展参数,不用手动导入根证书,也不容易漏掉 SAN。
我在团队里给新人演示本地 HTTPS 方案时,默认推荐 mkcert。它用起来几乎零门槛,而且跨平台一致。最常被问的问题“mkcert 生成的证书能上生产吗”,答案很明确:不能。它生成本地根证书的信任范围只是这台机器/设备,别人浏览器不带你这个根证书,当然不会信任。生产环境请继续用正规 CA,或者走 ACME 自动签发。
3.2 安装、初始化和生成第一张证书
macOS 上安装最简单:
brew install mkcertWindows 上可以用包管理器装,也可以拿到预编译的二进制直接跑;Linux 各发行版包管理器里多半也有。安装后先执行一次初始化:
mkcert -install这一步会生成本地根 CA,并把根证书导入当前系统的信任区。执行完之后,可以用:
mkcert -CAROOT查看生成的根证书保存在哪个目录。默认目录下会出现rootCA.pem和rootCA-key.pem,前者是公钥证书,后者是私钥。私钥同样要保护好,不要提交到仓库。
接着生成一个包含localhost和127.0.0.1的证书:
mkcert localhost 127.0.0.1 ::1命令跑完,当前目录会多出两个文件,默认文件名可能会根据参数生成,比如localhost+2.pem和localhost+2-key.pem。我已经习惯手动指定文件名,方便后续配置:
mkcert -key-file local-dev-key.pem -cert-file local-dev-cert.pem localhost 127.0.0.1 ::1然后把local-dev-cert.pem和local-dev-key.pem填到本地服务的 ssl 配置里就行。注意 mkcert 生成的证书里已经自动带上了 SAN,所以https://localhost和https://127.0.0.1都能直接访问。
3.3 多域名、多 IP 与自定义输出路径
本地开发不止一个域名。像我在项目里经常要测自定义域名dev.local,还要支持子域名api.dev.local、www.dev.local,那就把它们都加到同一个证书里:
mkcert dev.local "*.dev.local" localhost 127.0.0.1 ::1*.dev.local是一张通配符证书,能覆盖该域名下的任意子域名,很适合本地方案。如果需要局域网 IP 访问,也直接写:
mkcert 192.168.3.50 localhost 127.0.0.1 ::1生成后,用:
mkcert -CAROOT可以找到刚才自动导入的根证书,如果团队需要统一信任,可以把rootCA.pem发给同事,让他们手动安装,或者在他们机器上直接mkcert -install后自动建立信任。这里有个小坑:mkcert -install后生成的根证书,在不同机器上是不同的;如果希望团队里所有人的浏览器都信任同一套根证书,需要把其中一台机器生成的rootCA.pem和rootCA-key.pem分发到mkcert -CAROOT目录。但这个操作有安全风险,我更推荐“每台机器各装一次 mkcert,各自维护本机信任”。
3.4 配合前端开发服务器使用
现在很多前端构建工具支持直接读证书文件。以 Vite 为例,在vite.config.js里指定 HTTPS 选项:
import { defineConfig } from 'vite' import fs from 'fs' export default defineConfig({ server: { https: { key: fs.readFileSync('./certs/local-dev-key.pem'), cert: fs.readFileSync('./certs/local-dev-cert.pem') }, host: '0.0.0.0' } })启动之后,浏览器访问https://localhost:5173就不会再报错。如果项目里用的是 webpack-dev-server,配置项类似,同样提供https: { key, cert }。更简单一点,也可以装对应的 mkcert 插件,让构建工具启动时自动生成证书并注册信任,省去手动维护文件。但用插件有时会把证书目录搞得很隐蔽,我更喜欢在项目根目录放一个certs目录,把生成的两个 pem 文件集中管理,提交到.gitignore里,其他同事拉下来后自己运行 mkcert 生成。
4. 证书生成后最常见的翻车点与排查流程
4.1 证书链验证通过,浏览器仍然不信任
遇到证书明明没问题,浏览器还是报错,首先要确认自己导入的是不是正确的根证书。常见错误是导入站点证书而不是根证书,或者导入根证书时没有勾选“信任”。
在命令行验证证书链是否合法:
openssl verify -CAfile dev-ca.crt local-dev.crt如果输出 OK,说明证书链没问题。这时再去浏览器看证书详情,点击“查看证书”,确认“证书颁发机构”是不是你自己的根证书。如果浏览器里的信任列表找不到,说明系统信任那一步没有真正生效。Windows 上常见的问题在于证书导入到了“当前用户”的受信任证书区域,而浏览器进程以管理员身份运行,看到的信任区域不一致。建议全部导入“本地计算机 → 受信任的根证书颁发机构”,然后重启浏览器。
另外,Firefox 自带的证书库独立于系统,如果只做了系统导入,Firefox 依旧不买账,需要单独把根证书导到 Firefox 的证书管理里。这是老大难,别指望 Chrome 不报错,Firefox 就一定不报错。
4.2 访问 IP 或自定义域名提示“证书不安全”
证书链没问题、根证书也被信任了,但访问https://192.168.3.50依旧说“IP 地址不匹配”,十有八九是 SAN 里没写这个 IP。现代浏览器已经不看证书的 CN 字段了,只认 SAN 里的 DNS 或 IP。localhost 不会自动覆盖 192.168.3.50,127.0.0.1 也不等于局域网 IP。
排查手法很简单:
openssl x509 -in local-dev.crt -noout -text | grep -A1 "Subject Alternative Name"如果输出里没有你访问的那个地址,就重新生成一份证书,把对外的 IP/域名加进 SAN。mkcert 更直接,缺了就重新执行:
mkcert localhost 127.0.0.1 192.168.3.50别图省事只写一个域名,因为你的开发场景可能是“电脑上 localhost 能访问,手机要用 IP 访问同一个服务”,这时候证书必须同时包含两个地址。
4.3 明明换了新证书,浏览器还在用旧配置
有时证书更新了,浏览器访问还是旧信息,甚至一直跳转到底部警告页。这多半是浏览器缓存或 HSTS 在捣乱。
Chrome 里可以走chrome://net-internals/#hsts,在 Delete domain 一栏输入本地域名后删除 HSTS 记录。HSTS 的作用是强制浏览器用 HTTPS 访问某个域名,如果之前 HTTP 和 HTTPS 都试过,浏览器可能已经记住了“以后必须 HTTPS”,即使你关了服务器,浏览器也会优先跳到 HTTPS,然后因为证书不匹配报错。
新证书生成后,不要只是刷新页面,建议先开一个无痕窗口访问,或者完全退出浏览器再重新打开。Safari 的证书缓存尤其顽固,重启后基本能好。如果 Nginx 那边配置了多份证书路径,也可能因为配置写错而加载了旧证书,用这条命令确认服务真正发送的是哪张证书:
openssl s_client -connect localhost:443 -showcerts </dev/null看输出中s:那一行的 CN/SAN,就能直接判断服务端实际使用的是不是新证书。
4.4 反向代理、Docker 端口映射里的证书坑
把本地 HTTPS 证书放到反向代理和 Docker 场景里,问题会更多一层。比如你有一个容器跑在 8080 端口,Nginx 作为反代监听 443,把请求转发给容器的 8080 端口。这时候证书里的域名必须对应外部访问的域名,而不是容器内部的 service 名。否则浏览器访问https://localhost没问题,但反代回源时如果校验上游证书,可能报的是“证书和上游域名不匹配”。
另一种情况是用 Docker 起 Nginx,证书文件必须挂载进容器,并且路径要正确。常见的错误是宿主机路径能看到,但容器里的 Nginx 进程找不到,导致启动失败或 502。我会专门把证书目录挂载进去,例如:
volumes: - ./certs:/etc/nginx/certs:ro再检查 Nginx 配置里的路径是不是/etc/nginx/certs/...,而不是宿主机路径。
内网穿透或者映射到公网域名时,自签名证书基本不适用。因为公网域名对应的访问者浏览器不信任你的本地根证书,不可能要求用户手动导入。这种情况要么用真实域名证书,要么用带证书透明的公网 HTTPS 隧道方案,不要拿本地 CA 硬搞。
5. 证书的日常维护与安全边界
5.1 证书有效期怎么设,到期怎么自动续
本地证书也有有效期。根证书我习惯给十年,站点证书给 398 天以内。不要嫌 398 天太短,恰恰是短期证书才能逼你定期确认私钥没泄露、配置没过期。
如果不想每次手工敲命令,可以写一个简单的刷新脚本,核心就是重新走一遍第 2 章的签发流程。mkcert 用户需要的更少,重新执行一次mkcert命令覆盖文件即可。但要注意:mkcert 不会自动清理旧证书,最好在项目里约定好,把旧 pem 文件删掉再生成。我通常会在package.json里加一个gen:cert脚本,减少记忆成本:
{ "scripts": { "gen:cert": "mkcert -key-file certs/localhost-key.pem -cert-file certs/localhost.pem localhost 127.0.0.1 ::1" } }证书到期前,浏览器会给明显的告警,自己不用记日期。但反向代理、Docker 内部使用的证书有时不会显示警告,容易被忽略。建议日历加一条提醒,有效期前两周重新签发。
5.2 本地根证书和私钥绝不能带出去
我在网上见过不少人把整包证书目录直接推到代码仓库,里面既有rootCA.pem也有rootCA-key.pem,这是非常危险的事情。根证书私钥一旦泄露,任何人可以冒充你的本地 CA 签发证书,所有信任这个根证书的设备都会相信伪造站点。
保守做法是:rootCA.pem(公钥证书)可以拿出来分享,rootCA-key.pem(私钥)绝对留在自己机器;站点证书的私钥local-dev.key同样只应该存在于你自己和需要部署的服务上。代码仓库里统一加.gitignore,把*.key全部忽略:
*.key !sample.key另外,不要把开发根证书导入到生产服务器,也不要为了“方便”把某台内网机器的开发根证书复制到公网服务器。开发信任域和生产信任域分离,是避免出安全事故的基本底线。如果生产环境真的需要内部证书,请用独立的内部 CA,不要拿开发环境的根证书凑合。
5.3 团队里共享一套开发 CA 的正确姿势
团队开发时,如果每个人都各自执行mkcert -install,那么每个人拥有的根证书都不同。这样并不是不行,只是每个人都要手动信任自己本机上的根证书,访问同事电脑的服务时容易出问题。
更顺滑的做法是确定一套团队内统一的开发根证书:由某一位成员生成根证书,只把rootCA.pem和安装说明发给其他成员,每个人导入系统信任库;而站点证书各自在自己的机器上用同一根证书签发,确保每个人的站点证书 SAN 覆盖自己机器的 IP。
如果你不想共享私钥,又希望同事之间能互访彼此电脑上的 HTTPS 服务,比较合理的方式是:每个人用自己的 mkcert 生成证书时,统一把团队里常用 IP 列表加进去,然后把各自的公钥证书放到一个内网同步目录。这样虽然根证书不同,但每台机器互相导入对方证书作为站点信任证书,也能工作,只是维护成本高。
我个人现在的习惯是:根证书和私钥只放在电脑的~/certs/dev-ca目录,Never 进仓库;每个项目的证书统一生成到项目certs/目录,并在.gitignore里忽略;每次新同事加入,先让他们mkcert -install,再给他们一份证书生成命令模板。这样既保留了 SSL 功能,又不会让信任体系变得越来越乱。本地 HTTPS 证书这件事,解决一次,后续收益很大。