# Flutter HandshakeException: CERTIFICATE_VERIFY_FAILED fix

> Flutter throws CERTIFICATE_VERIFY_FAILED when dart:io cannot verify the server certificate. Read the reason after the colon, then fix the chain.

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

**`HandshakeException: Handshake error in client (OS Error: CERTIFICATE_VERIFY_FAILED ...)` means Dart's TLS stack could not verify the server's certificate, so the connection was closed before any HTTP request.** Flutter apps hit it through `HttpClient`, `package:http` or Dio, which all use `dart:io` on mobile. The part after `CERTIFICATE_VERIFY_FAILED:` is the real diagnosis, and the fix depends on it.

## Read the reason after the colon

The text after `CERTIFICATE_VERIFY_FAILED:` names the check that failed, so read it first. The full message looks like this (the source file and line at the end vary by version):

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

Dart builds it from BoringSSL: the reason code `CERTIFICATE_VERIFY_FAILED` followed by the certificate verification result string.

| Text after the colon | Meaning | Section |
|---|---|---|
| `unable to get local issuer certificate` | No trusted issuer found for a certificate in the chain | 1, 3 |
| `self signed certificate` | The server's certificate signs itself | 2 |
| `self signed certificate in certificate chain` | The chain ends in a root the device does not trust | 2 |
| `Hostname mismatch` | The certificate does not cover the host you called | 4 |
| `certificate has expired` / `certificate is not yet valid` | Dates, or a wrong device clock | 5 |

## Not to be confused with

`HandshakeException: Connection terminated during handshake` is a different failure: the connection closed before certificates were checked, which points to a protocol mismatch, a proxy or a server that dropped the connection, not to trust. Other `HandshakeException` messages without `CERTIFICATE_VERIFY_FAILED`, such as `WRONG_VERSION_NUMBER`, mean the server did not speak TLS on that port. For general handshake failures start at [SSL handshake failed](https://ssl.sh/errors/ssl-handshake-failed/).

## How Dart decides what to trust

Dart relies on the platform's system roots and never downloads missing intermediates, so the server must send a complete chain.

- **Android:** Dart does not bundle root certificates. It reads the device's system CA directory, `/apex/com.android.conscrypt/cacerts` on Android 14 and later or `/system/etc/security/cacerts` otherwise. User-installed CAs are not included.
- **iOS and macOS:** Dart hands the chain to Apple's trust evaluation, adding any certificates you configured in a `SecurityContext`.
- **Windows and Linux desktop:** the operating system's root store or standard CA bundle paths.

## Common causes, most frequent first

### 1. The server does not send its intermediate certificate

This is the most frequent case, and the reason "it works in Chrome". Count the certificates the server sends:

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

One certificate from a public CA means the chain is incomplete. Fix it on the server; see [missing intermediate certificate](https://ssl.sh/errors/missing-intermediate-certificate/).

### 2. Development server or debugging proxy with a private CA

The app talks to a local HTTPS server, a staging API with an internal CA, or through Charles, Proxyman or mitmproxy. On Android, installing the proxy's CA in the system settings does not help, because Dart reads only the system CA directory.

### 3. Old Android device missing a root

Older Android releases do not contain newer roots in their system store. Since Dart relies on that store, certificates that chain only to those roots fail on those phones; see [old Android and Let's Encrypt](https://ssl.sh/errors/old-android-lets-encrypt-error/).

### 4. Hostname mismatch

The app calls an IP address, an internal alias, or `10.0.2.2` from the Android emulator, which Android documents as the special alias for the development machine's loopback interface. The certificate lists other names.

### 5. Wrong device clock

A device whose date is far off rejects valid certificates as expired or not yet valid. Enable automatic date and time.

## How to fix it

Fix the server for production, and trust a private CA explicitly only in debug builds.

**Production servers:** serve the full chain (`fullchain.pem` in Nginx, the intermediate in the chain file elsewhere) and check it with the [chain builder](https://ssl.sh/tools/chain-builder/).

**Development and private CAs:** trust your CA explicitly, only in debug builds:

```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);
}
```

With Dio, return this client from `IOHttpClientAdapter(createHttpClient: ...)`. With `package:http`, wrap it in `IOClient`.

**Avoid** `client.badCertificateCallback = (cert, host, port) => true;` in anything that ships. Dart's documentation describes the callback as deciding whether to accept a certificate "that cannot be authenticated by any of our trusted root certificates"; returning true accepts all of them.

**Emulator:** issue the development certificate with every name and IP the app uses, including `10.0.2.2` if needed.

## How to verify the fix

1. `openssl s_client -connect api.example.com:443 -servername api.example.com -verify_return_error </dev/null` ends with `Verify return code: 0 (ok)`.
2. Run the app on a physical Android device and on iOS; both should connect without a callback override.
3. Search the codebase for `badCertificateCallback` and make sure nothing returns `true` outside debug code. Recheck the public endpoint with the [SSL checker](https://ssl.sh/tools/ssl-checker/).
