
1. 开发环境为什么要折腾自签名证书先说个场景你本地起了个服务接口跑得欢结果前端一接WebSocket发现被浏览器拦了提示是HTTPS页面调用HTTP接口被混合内容策略干掉了。或者你在调试小程序、PWA、扫码登录这些需要安全上下文的能力没有HTTPS环境就根本没法往下走。买证书一年几百块域名还得备案本地IP又不是公网域名证书签了也绑不上。这时候自签名证书就是开发环境的标配工具。其实说白了自签名证书就是一个自己给自己当CA签发的证书不需要向任何第三方机构申请。浏览器一开始会警告“不受信任”因为你的系统证书库里没有这个CA但只要你在系统或浏览器里把这个自签名CA导进去信任它就跟正规证书一样工作HTTPS加密、安全上下文全部正常。整个过程用OpenSSL一条命令加一个配置文件就能完成熟练的话五分钟确实够用。这篇文章适合谁所有在本地或内网环境开发、测试、调试web服务的人尤其是要处理多IP访问的场景。比如你的开发机同时有127.0.0.1、192.168.1.100这种局域网IP或者你跑Docker容器要从宿主机访问容器服务那光给一个IP写证书肯定不够。我在实际项目里踩过不少关于SANSubject Alternative Name主题备用名称配置的坑这篇文章把这些经验都整理出来从原理到命令到排错一条龙保证你看完能直接复现。2. 自签名证书的底层逻辑证书里面到底装了什么2.1 公钥、私钥和签名三个东西的协作关系要搞明白自签名证书先得理解证书这个文件本身是什么。证书不是私钥它是一段结构化的数据里面装着你的公钥、主体标识域名或IP、有效期再加上CA对这段数据的签名。CA签名是什么意思就是CA用自己的私钥对证书内容做了一次摘要加密保证这份证书的内容没被篡改过。浏览器验证证书时用CA的公钥去解开这个签名对比内容和摘要是否一致一致就说明证书是真的由这个CA签发的。自签名证书的不同之处在于签发者和使用者是同一个实体。也就是说一份自签名证书的Issuer签发者和Subject使用者字段完全相同等于你自己给自己做了个身份认证并签字盖章。这本身不存在什么技术问题问题只在于浏览器的信任列表里没有你的公钥所以它不认。解决办法后面会说就是把你这个自签名的CA证书导入系统的信任库。密钥对生成时你有两个选择RSA和ECDSA。我本地开发用RSA 2048比较多兼容性最稳特别是老设备、老系统。ECDSA的p-256在CPU开销上更小但部分场景比如某些旧版Android WebView识别会有问题。如果你只在自己电脑的Chrome上调试用ECDSA完全没问题实际测试握手会快一点。生成命令是openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes-nodes这个参数是“no DES”的意思表示生成的私钥不加密不要求输入密码。开发环境一定要加这个参数不然每次启动nginx或者Node服务都要输入一遍密钥口令烦不胜烦而且脚本化跑自动化测试也会被卡住。生产环境另说私钥加密是底线。2.2 SAN是什么为什么多IP必配SAN你可能会想我在生成证书时不是指定了Common NameCN通用名吗CN填了IP地址为什么浏览器还是报域名不匹配这是很多人踩过的第一个坑。从某个版本的浏览器策略开始CN字段在HTTPS域名匹配上基本已经废了。现在浏览器验证的是证书里的SAN扩展。如果SAN为空Chrome直接从证书信息中读取subjectAltName字段找不到就直接报NET::ERR_CERT_COMMON_NAME_INVALID。CN字段现在的作用更像一个备注标签部分老工具和命令行程序还会参考它但浏览器已经不认了。SAN可以填很多内容DNS类型可以填域名IP类型可以填IP地址。注意两种类型在OpenSSL配置里写法不一样DNS.1和IP.1不能混用IP地址写在DNS里会被当成域名来处理浏览器匹配会失败。开发场景里最典型的就是“一台机器多个访问入口”。你机器上有回环地址127.0.0.1、局域网地址192.168.1.100还可能在Docker里映射了一个172.17.0.x的内部IP或者你同时要给一个临时域名比如localhost、dev.local签发。这时候SAN里就得把所有这些地址全列上缺一个就有一个入口访问时会报证书错误。我在配置过程中遇到过一个比较隐蔽的情况某些浏览器在IPv6环境访问时会优先走::1这个地址如果你的SAN只写了IPv4的127.0.0.1直接报错。稳妥起见本机访问的证书把::1也写进去。2.3 签名算法和有效期选择的门道你可能会想说证书的签名算法是不是越新越好其实这里面有个兼容性的取舍。OpenSSL 3.0默认的签名算法是SHA256这个基本所有现代系统都支持。不要为了追求“更安全”去尝试SHA512之类的一方面证书体积变大另一方面某些嵌入式设备、老系统的TLS栈可能不识别。开发环境没必要给自己加戏。有效期方面我建议开发证书给365天。为什么不是更长因为正式CA签发的证书最多也就398天这是行业共识。你自己签个十年有效期的证书当然技术上可行但有个实际问题很多系统和浏览器引入了“证书透明度策略”和“最大有效期校验”特别是Chrome对有效期超过398天的证书会有额外警告机制。虽然自签名证书不一定触发但为了避免这些奇奇怪怪的坑老老实实签一年就好。到期之后重新签一份把脚本里的-days参数改成365再执行一遍就行。# 查看证书有效期 openssl x509 -in cert.pem -noout -dates3. 五分钟实操生成包含多IP的OpenSSL自签名证书3.1 准备配置文件openssl.cnf先把配置文件写好这是整个流程里最能提高效率的一步。很多人图省事用命令行直接怼参数结果临时想加一个IP就得重新生成一遍证书。正确的做法是把所有配置固化到一个文件里版本管理起来改IP就改文件再跑一次命令即可。用任意文本编辑器创建一个openssl.cnf文件[req] distinguished_name req_distinguished_name req_extensions v3_req x509_extensions v3_ca prompt no [req_distinguished_name] countryName CN stateOrProvinceName Beijing localityName Beijing organizationName LocalDev organizationalUnitName IT commonName localhost [v3_ca] subjectKeyIdentifier hash authorityKeyIdentifier keyid:always,issuer basicConstraints critical, CA:true keyUsage critical, digitalSignature, keyEncipherment, keyCertSign subjectAltName alt_names [alt_names] DNS.1 localhost DNS.2 dev.local IP.1 127.0.0.1 IP.2 ::1 IP.3 192.168.1.100 IP.4 172.17.0.1解释几个关键配置prompt no不弹出交互式提问所有字段从配置里读。这是脚本化和快速执行的基础。basicConstraints critical, CA:true这个字段声明证书具备CA签名能力。如果你是生成用于服务端HTTPS的“叶子证书”这里应该写成CA:false。我会在后面专门讲这两种用途的区别。keyUsage声明密钥用途digitalSignature用来做数字签名keyEncipherment用来做密钥交换。keyCertSign配合CA:true使用。subjectAltName alt_names引用下面的SAN配置段。这里的commonName localhost只是作为“友好名称”存在真正起到域名匹配作用的是alt_names里的DNS和IP条目。3.2 生成密钥和证书命令分解配置文件准备好之后执行下面的命令openssl req -x509 -newkey rsa:2048 \ -keyout server.key \ -out server.crt \ -days 365 \ -nodes \ -config openssl.cnf参数说明req -x509生成自签名证书。-x509选项告诉OpenSSL直接输出证书而不是生成证书签名请求CSR。-newkey rsa:2048同时生成新的RSA私钥长度2048位。-keyout server.key私钥输出文件名。-out server.crt证书输出文件名。Linux下一般用.crt后缀Windows习惯用.cer或.crtmacOS两者都能识别。-config openssl.cnf指定配置文件。执行完当前目录会出现两个文件。私钥server.key的权限要注意建议立刻改成600chmod 600 server.key开发环境很多人不在意私钥权限但在团队合作的机器上其他用户可以轻易读走你的私钥这等于把你的HTTPS身份拱手送人。改一下权限不费事但这个习惯能帮你避免很多麻烦。如果你已经有私钥、只想补一张证书就用下面的流程# 第一步基于已有私钥生成证书签名请求 openssl req -new -key server.key -out server.csr -config openssl.cnf # 第二步用同一私钥签发证书自签名自签名时可以直接从CSR签也可以重新写配置 openssl x509 -req -days 365 -in server.csr -signkey server.key -out server.crt -extensions v3_ca -extfile openssl.cnf这种方式适合以后要接入正规CA的场景。你先用自己的私钥生成CSR提交给CA机构规章制度合规之后再去替换证书文件私钥不用重新生成。不过开发环境直接用第一条命令就够了省去中间步骤。3.3 验证证书内容是否正确证书生成完先别急着配置服务检查一下内容再投入使用。用OpenSSL查看证书细节openssl x509 -in server.crt -noout -text重点看两个地方。一是SAN部分X509v3 Subject Alternative Name: DNS:localhost, DNS:dev.local, IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1, IP Address:192.168.1.100, IP Address:172.17.0.1::1被展开成完整形式属于正常现象不用慌。二是有效期和签名算法Not Before: Jul 12 03:15:22 2025 GMT Not After : Jul 12 03:15:22 2026 GMT Signature Algorithm: sha256WithRSAEncryption如果SAN里没有你要的IP或者有效期不对趁现在改配置文件重新生成别等装到服务上再排查。4. 两种典型用途自建CA签发叶子证书 vs 直接使用根证书4.1 自建CA的完整链路刚才我们生成的证书带了CA:true可以直接当CA证书用也能直接作为服务器证书使用。但在一个更规范的开发环境里我建议你分两步走生成一个独立的CA根证书然后用这个CA去签发服务器证书。这个结构的价值体现在两个方面一你只需要在一台机器上安装一次CA根证书以后签发的所有服务器证书都自动被信任。如果你在多个环境本地开发、测试服务器、Docker内部都要用证书每签一张新证书就装一次根证书显然太繁琐装了CA之后就一劳永逸了。二更接近生产环境的证书链结构。生产环境是“Root CA - Intermediate CA - Server Cert”本地开发虽然不用模拟得那么深但至少理解“服务端证书由CA签发”这个模型排查问题时思路更清晰。具体操作是这样。第一步生成CA根证书openssl req -x509 -newkey rsa:4096 \ -keyout ca.key \ -out ca.crt \ -days 3650 \ -nodes \ -subj /CCN/STBeijing/LBeijing/OLocalDevCA/CNLocalDev Root CACA根证书有效期给十年没问题因为它的用途就是“信任锚”你不希望它经常过期。第二步用同一个openssl.cnf生成服务器私钥和CSRopenssl req -newkey rsa:2048 \ -keyout server.key \ -out server.csr \ -nodes \ -config openssl.cnf \ -subj /CCN/STBeijing/LBeijing/OLocalDev/CNlocalhost第三步用CA私钥签发服务器证书openssl x509 -req \ -in server.csr \ -CA ca.crt \ -CAkey ca.key \ -CAcreateserial \ -out server.crt \ -days 365 \ -extensions v3_ca \ -extfile openssl.cnf这一步有几个细节值得展开。-CAcreateserial参数表示如果要生成CA序列号文件ca.srl就自动创建。CA每次签发证书都会维护一个自增序列号这个序列号会写进证书里用于吊销列表追踪。你不用去动它OpenSSL自己管理。-extensions v3_ca引用配置文件里的扩展段。注意这里用的是同一个openssl.cnf但里面的v3_ca段带有CA:true这个配置其实不适合服务器证书。正确姿势是单独建一个server_ext.cnf[v3_server] subjectKeyIdentifier hash authorityKeyIdentifier keyid,issuer basicConstraints critical, CA:false keyUsage critical, digitalSignature, keyEncipherment subjectAltName alt_names [alt_names] DNS.1 localhost DNS.2 dev.local IP.1 127.0.0.1 IP.2 ::1 IP.3 192.168.1.100 IP.4 172.17.0.1签发命令里改成openssl x509 -req \ -in server.csr \ -CA ca.crt \ -CAkey ca.key \ -CAcreateserial \ -out server.crt \ -days 365 \ -extensions v3_server \ -extfile server_ext.cnf这样做出来的证书链路完整、字段干净部署到nginx或Node服务里浏览器验证时能正确构建出“服务器证书 - CA根证书”的信任链。4.2 直接使用自签根证书作为服务端证书的问题如果你图省事直接用第一步生成的ca.crt作为服务端证书挂到nginx上浏览器大概率也能跑通前提是你把ca.crt导入到了系统信任库。但有个隐患因为这个证书带有CA签名能力且有效期十年它本质上是一个“万能钥匙”一旦泄露攻击者可以用它签发任意域名的证书你的HTTPS防线等于不存在。开发环境不面临这种高等级威胁但养成好习惯没坏处。把“CA根证书”和“服务器证书”分开维护本质上是操作成本极低、安全收益明显的一件事。我实际项目中就是这么做的每次在server_ext.cnf里改IP然后重新签一张叶子证书根证书十年不动。5. 证书部署从nginx到Node再到Docker5.1 nginx配置HTTPS站点配置好证书之后部署到nginx是最常见的用法。在nginx的配置文件中加一个server块server { listen 443 ssl; server_name localhost; ssl_certificate /etc/nginx/ssl/server.crt; ssl_certificate_key /etc/nginx/ssl/server.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } }检查配置并重新加载nginx -t nginx -s reload这里有两个点要注意。ssl_ciphers别去抄网上那些特别长的推荐串运行环境不一样最优解不一样。本地开发用默认安全等级就够了重点是ssl_protocols别开TLSv1.0和TLSv1.1这俩协议有已知漏洞而且新版浏览器直接不支持了。反向代理到HTTP后端服务时X-Forwarded-Proto很重要。如果后端的应用有“检测到HTTPS请求就跳转”的逻辑你没传这个头它会以为客户端用的是HTTP然后强制302跳到HTTPS形成死循环。5.2 Node.js/Express服务内置HTTPSNode服务不需要外层nginx的话直接用https模块加载证书const https require(https); const fs require(fs); const express require(express); const app express(); const options { key: fs.readFileSync(./server.key), cert: fs.readFileSync(./server.crt) }; app.get(/, (req, res) { res.send(Hello HTTPS); }); https.createServer(options, app).listen(8443, () { console.log(HTTPS server running on https://localhost:8443); });注意端口选8443而不是443因为443端口在本地经常被占用而且非root用户无法直接监听1024以下的端口。开发环境换端口最省心。启动后用curl验证一下curl -k https://localhost:8443-k参数跳过证书校验因为curl也没有读取你系统信任的证书相当于临时绕过验证适合快速检查服务本身是否工作。要测试完整链路用之前导入CA到系统信任后不加-k再访问一次。5.3 Docker容器内的证书挂载跑Docker容器时把证书文件映射进容器是最常见的做法docker run -d \ -p 443:443 \ -v $(pwd)/server.crt:/etc/nginx/ssl/server.crt:ro \ -v $(pwd)/server.key:/etc/nginx/ssl/server.key:ro \ nginx注意ro参数表示只读挂载。运行中的容器不应该对证书文件有写权限防止容器被攻破后篡改证书。如果你是在容器内生成证书那么容器里的openssl版本和主机最好保持一致否则可能出现新版本算法旧版解析不了的问题。简单来说统一用OpenSSL 3.x就没什么坑。5.4 系统信任导入macOS、Windows和Linux证书部署完还需要让系统信任它。这一步也是最容易劝退新手的地方因为各家系统的操作路径完全不一样。macOS上用钥匙串访问导入sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ca.crt-r trustRoot表示信任根证书的全部能力-d表示作为系统级信任。如果只在当前用户生效把-k替换成登录钥匙串的位置即可或者直接双击ca.crt钥匙串访问会弹出导入界面手动把信任改为“始终信任”。Windows上双击ca.crt文件选择“安装证书”存储位置选“本地计算机”然后“将所有证书都放入下列存储”浏览选择“受信任的根证书颁发机构”。Linux上不同发行版路径不同# CentOS/RHEL sudo cp ca.crt /etc/pki/ca-trust/source/anchors/ sudo update-ca-trust # Ubuntu/Debian sudo cp ca.crt /usr/local/share/ca-certificates/ sudo update-ca-certificates导入完成后完全重启浏览器不是关标签页是彻底退出进程再重开证书才会被重新加载。有一个非常容易踩的坑你导入了根证书但浏览器仍然报证书错误。大概率是浏览器进程缓存了旧的证书信任状态需要强制重启。Chrome直接在地址栏输入chrome://restartFirefox在地址栏输入about:profiles逐个Profile重启Safari就退出重进。6. 常见问题与排查技巧实录6.1 “证书名不匹配”类错误的完整排查流程浏览器报ERR_CERT_COMMON_NAME_INVALID意思不是证书失效而是浏览器在证书里找不到当前访问地址对应的条目。排查顺序是固定的。先确认你在浏览器地址栏里访问的地址是什么。如果你访问https://192.168.1.100:8443那证书的SAN里必须有IP:192.168.1.100这条。如果你访问https://localhost:8443那证书的SAN里必须有DNS:localhost这条。看起来简单但很多人把IP写进DNS条目里导致明明是IP访问却匹配失败。用OpenSSL快速查看证书的SANopenssl x509 -in server.crt -noout -ext subjectAltName如果输出里确实有对应条目那问题就出现在“浏览器没有重新加载证书”上。彻底关闭浏览器重新打开再访问。如果证书确实没有对应条目修改openssl.cnf的alt_names重新生成证书并重启服务。6.2 Chrome新版本对有效期和CT策略的额外警告Chrome更新到较新版本之后对证书的检查更严格了。即使你的证书在系统信任库里只要有效期超过398天或者没有SCTSigned Certificate Timestamp扩展地址栏会出现“不安全”的灰色提示。这个灰色提示对自签名证书来说基本无解因为SCT只有正规CA签署证书时才会附加。好在它只是警告HTTPS功能本身能正常使用绝大多数开发调试不受影响。遇到这种情况不要浪费时间研究怎么消除警告直接在功能层面确认是否能正常通信就行。如果你实在介意这个灰色提示可以换Firefox做调试浏览器Firefox对自签名证书的信任策略相对宽松一些。6.3 系统时间不对导致证书“未生效”一个隐蔽的问题是系统时间不对。证书里Not Before是一个绝对时间点如果系统时间在这个时间点之前TLS握手直接失败报证书尚未生效错误。开发机经常出现这个问题特别是双系统环境的机器启动后RTC时间错乱系统从网络时间服务器同步之前证书校验就挂了。解决思路很简单先对时然后重新生成证书。如果你经常遇到这种问题建议生成证书时把-days参数稍微放宽一点或者用脚本每天校准一次系统时间。6.4 如何让局域网内的同事也信任你的证书你签发的证书要让同事的电脑信任只需要把ca.crt文件发给他们按5.4节里的方式导入到对方系统的信任库即可。注意一点你不要发server.crt给同事只发ca.crt。因为server.crt是叶子证书导入到信任库不能起到“信任链”的作用而ca.crt是签发者导入它能自动推导出所有由它签发的证书都是可信的。还有一个场景你不想让同事手动操作导入也可以把ca.crt放在内网一个静态文件服务器上用浏览器打开并下载然后双击导入。本质上还是让每个人手动信任这个CA只是省去了传输环节。6.5 常见问题速查表现象根本原因解决手段Chrome报NET::ERR_CERT_COMMON_NAME_INVALIDSAN缺少对应DNS或IP条目修改openssl.cnf重新签发报NET::ERR_CERT_AUTHORITY_INVALIDCA根证书未导入系统信任库导入ca.crt到系统信任重启浏览器报NET::ERR_CERT_DATE_INVALID系统时间不对或证书过期校准时间重新生成证书报证书链不完整服务器只配置了叶子证书没有附带CA证书nginx的ssl_certificate配置里把CA链拼进去访问localhost正常访问局域网IP报错SAN只有DNS条目没有IP条目在alt_names补充IP地址Node服务报self signed certificate错误没有指定ca参数或NODE_EXTRA_CA_CERTS环境变量设置NODE_EXTRA_CA_CERTS/path/to/ca.crt最后一条单独提一下。Node.js运行时的HTTPS客户端axios、node-fetch等不会自动读取系统的证书信任库它用的是自己内置的CA列表。你要让它信任自签名CA需要设置环境变量export NODE_EXTRA_CA_CERTS/path/to/ca.crt node app.js这个环境变量在Node.js 12.3.0以上版本才支持老项目升级Node版本后这个功能才可用。设置完之后Node侧的HTTPS请求就不会再抛self signed certificate的异常了。7. 日常使用的一些经验和补充最后一次生成证书时我顺便把整个过程写成了一个shell脚本放在项目里每次要重建环境就执行一次几十秒搞定#!/bin/bash # 检查openssl是否存在 if ! command -v openssl /dev/null; then echo 请先安装openssl exit 1 fi # 生成CA首次需要 if [ ! -f ca.crt ]; then openssl req -x509 -newkey rsa:4096 \ -keyout ca.key -out ca.crt -days 3650 -nodes \ -subj /CCN/STBeijing/LBeijing/OLocalDevCA/CNLocalDev Root CA echo CA根证书已生成 fi # 生成服务器证书 openssl req -newkey rsa:2048 \ -keyout server.key -out server.csr -nodes \ -config openssl.cnf \ -subj /CCN/STBeijing/LBeijing/OLocalDev/CNlocalhost openssl x509 -req \ -in server.csr \ -CA ca.crt -CAkey ca.key -CAcreateserial \ -out server.crt -days 365 \ -extensions v3_server -extfile server_ext.cnf echo 服务器证书生成完毕 echo 证书文件server.crt echo 私钥文件server.key这个脚本有个小细节值得说明if [ ! -f ca.crt ]判断CA文件是否存在存在就跳过避免重复生成CA导致之前信任过它的机器全部失效。每次CI跑脚本即使覆盖了server.crt和server.key也不影响其他开发者的信任状态。如果你要同时管理多个项目每个项目用一套证书建议在项目根目录建一个certs/文件夹把对应的openssl配置文件、脚本、密钥都放一起。不要图省事放到全局共享目录否则一个项目改了IP重签证书其他项目的服务端证书全部失效排查起来费老劲了。还有个小技巧排查证书问题时用openssl s_client直接连端口看服务端实际下发证书内容比你翻nginx日志高效得多echo | openssl s_client -connect 127.0.0.1:8443 -servername localhost 2/dev/null | openssl x509 -noout -subject -issuer -dates这个命令能看到服务器实际返回的证书Subject和Issuer如果Issuer显示的是你自己的CA名说明证书链正确如果显示的和服务器配置里的证书不一致多半是加载了老文件没重启服务。实战里我靠这个命令定位过好几次“改了配置没有重启”的低级失误。自签名证书这套流程本质上就是一个本地CA管理系统从生成到安装到排错一条链路。掌握了原理不管以后遇到什么奇怪的证书问题多跑几次openssl命令多看证书内容基本都能自己解决。开发环境图的就是省事高效现在五分钟左右搞定的配置库以后每个新项目都能直接复用省下的时间是真不少。