# HandshakeException CERTIFICATE_VERIFY_FAILED（Flutter）怎么修

> Flutter/Dart 报 CERTIFICATE_VERIFY_FAILED，是 dart:io 无法验证服务器证书。先看冒号后面的具体原因，再修证书链或信任设置，附 Dio 写法。

Source: https://ssl.sh/zh/errors/flutter-handshake-exception-certificate-verify-failed/
Last updated: 2026-11-10

**`HandshakeException: Handshake error in client (OS Error: CERTIFICATE_VERIFY_FAILED ...)` 的意思是：Dart 的 TLS 栈验证不了服务器证书，连接在发出任何 HTTP 请求之前就被关闭了。** Flutter 应用不管用的是 `HttpClient`、`package:http` 还是 Dio，在手机上底层都是 `dart:io`，都会遇到它。具体原因写在 `CERTIFICATE_VERIFY_FAILED:` 后面，修法也取决于这部分。

## 先看冒号后面写的什么

冒号后面的文字决定修法，最常见的是 `unable to get local issuer certificate`。完整的报错长这样（最后括号里的源文件和行号随版本变化）：

```text
HandshakeException: Handshake error in client (OS Error:
	CERTIFICATE_VERIFY_FAILED: unable to get local issuer certificate(handshake.cc:393))
```

Dart 把 BoringSSL 的原因码 `CERTIFICATE_VERIFY_FAILED` 和证书校验结果拼在一起：

| 冒号后面的文字 | 含义 | 对应小节 |
|---|---|---|
| `unable to get local issuer certificate` | 链里某张证书找不到受信任的签发者 | 1、3 |
| `self signed certificate` | 服务器证书是自签名的 | 2 |
| `self signed certificate in certificate chain` | 链的终点是设备不信任的根 | 2 |
| `Hostname mismatch` | 证书没有覆盖你访问的主机名 | 4 |
| `certificate has expired` / `certificate is not yet valid` | 日期问题，或者设备时间不对 | 5 |

## 别和这些报错搞混

`HandshakeException: Connection terminated during handshake` 是另一回事：连接在检查证书之前就断了，原因在协议不匹配、代理或服务器主动断开，而不是证书信任。不带 `CERTIFICATE_VERIFY_FAILED` 的其他 `HandshakeException`，比如 `WRONG_VERSION_NUMBER`，说明那个端口上跑的不是 TLS。证书校验以外的握手失败，从 [SSL 握手失败总览](https://ssl.sh/zh/errors/ssl-handshake-failed/) 看起。

## Dart 信任哪些证书

Dart 用的是各平台的系统信任库，不读用户安装的 CA，也不自动下载缺失的中间证书。

- **安卓：** Dart 不自带根证书，而是读取设备的系统 CA 目录：Android 14 及以上是 `/apex/com.android.conscrypt/cacerts`，更早的版本是 `/system/etc/security/cacerts`。用户自己安装的 CA 不在其中。
- **iOS 和 macOS：** Dart 把证书链交给苹果系统做信任校验，再加上你在 `SecurityContext` 里配置的证书。
- **Windows 和 Linux 桌面：** 操作系统的根证书库，或者标准 CA 证书包路径。

Dart 在安卓上不走 Java 那套网络栈，而是用自带的 BoringSSL 直接读取系统 CA 目录。所以你在安卓工程的 `network_security_config.xml` 里写的信任设置（比如信任用户证书、信任某个自定义 CA），对原生 Java/Kotlin 代码有效，对 `dart:io` 发出的请求不起作用。同一个接口，原生页面正常、Flutter 页面报错，往往就是这个原因。

## 推荐的排查顺序

排查从冒号后面的原因文字开始，先查服务器证书链，再查设备，最后才动代码里的信任设置。

1. 记下冒号后面的原因文字，对照上面的表格判断属于哪一类。
2. 用 `openssl s_client -showcerts` 看服务器发了几张证书、证书里有哪些域名、有效期是否正常。
3. 在另一台较新的安卓真机和 iOS 设备上各跑一次，判断是不是只有某台设备的问题。
4. 确认请求的地址和证书里的域名完全一致，没有用 IP 或别名。
5. 最后才考虑在代码里调整信任设置，而且只在调试版里调整。

## 常见原因（按出现频率排序）

最常见的原因是服务器没发中间证书：浏览器能自己补上，Dart 不会。

### 1. 服务器没发中间证书

“浏览器里明明正常”多半就是这个原因。在阿里云、腾讯云下载证书后手工部署时，如果只用了证书文件里的第一段，或者 Nginx 配成了不含中间证书的文件，就会这样。数一数服务器发了几张证书：

```bash
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null 2>/dev/null \
  | grep -c 'BEGIN CERTIFICATE'
```

公共 CA 的证书只返回 1 张，就是链不完整，要在服务器上修，见 [缺少中间证书](https://ssl.sh/zh/errors/missing-intermediate-certificate/)。

### 2. 开发服务器或抓包代理用了内部 CA

本地起的 HTTPS 服务、用内部 CA 的测试环境接口，或者中间挂了 Charles、Fiddler、Proxyman、mitmproxy。在安卓上，把代理的 CA 装进系统设置也没用，因为 Dart 只读系统 CA 目录。抓包时 Flutter 应用的请求抓不到或直接报这个错，原因就在这里。

### 3. 老安卓设备缺根证书

老版本安卓的系统证书库里没有较新的根证书。Dart 依赖这个证书库，只链到这些新根的证书在这些手机上就会失败，见 [老安卓与 Let's Encrypt](https://ssl.sh/zh/errors/old-android-lets-encrypt-error/)。

### 4. 主机名不匹配

应用访问的是 IP、内部别名，或者在安卓模拟器里访问 `10.0.2.2`（安卓文档说明这是指向开发电脑本机回环接口的特殊别名），而证书里写的是别的名字。

### 5. 设备时间不对

日期偏差很大的设备会把正常证书判为过期或尚未生效。打开自动设置日期和时间即可。

## 怎么修

生产环境在服务器上补全证书链，开发环境只在调试版里信任内部 CA，发布版不放行任何证书。

**生产服务器：** 发送完整证书链（Nginx 用 `fullchain.pem`，其他服务器把中间证书放进证书链文件），再用 [证书链工具](https://ssl.sh/zh/tools/chain-builder/) 检查。

**开发环境和内部 CA：** 只在调试版里显式信任你的 CA：

```dart
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';

Future<HttpClient> buildClient() async {
  final context = SecurityContext(withTrustedRoots: true);
  if (kDebugMode) {
    final ca = await rootBundle.load('assets/dev-ca.pem');
    context.setTrustedCertificatesBytes(ca.buffer.asUint8List());
  }
  return HttpClient(context: context);
}
```

用 Dio 的，在 `IOHttpClientAdapter(createHttpClient: ...)` 里返回这个 client；Dio 在手机上默认的适配器就是 `IOHttpClientAdapter`，底层正是 `dart:io` 的 `HttpClient`。用 `package:http` 的，把它包进 `IOClient`。

**不要**在任何要发布的代码里写 `client.badCertificateCallback = (cert, host, port) => true;`。Dart 文档对这个回调的描述是：决定是否接受一张无法被任何受信任根证书验证的证书；返回 true 就是全部接受。

**模拟器：** 签发开发证书时把应用会用到的域名和 IP 都写进去，需要的话包括 `10.0.2.2`。

## 怎么确认已经修好

修好的标准是：`openssl` 验证返回 0，真机不覆盖回调也能连上，发布代码里没有返回 `true` 的回调。

1. `openssl s_client -connect api.example.com:443 -servername api.example.com -verify_return_error </dev/null` 最后显示 `Verify return code: 0 (ok)`。
2. 在安卓真机和 iOS 上分别运行，不覆盖回调也能正常连接。
3. 在代码里搜索 `badCertificateCallback`，确认调试代码之外没有任何地方返回 `true`。公网接口再用 [SSL 检测](https://ssl.sh/zh/tools/ssl-checker/) 复查一遍。
