Phones and operating systems

Flutter HandshakeException: CERTIFICATE_VERIFY_FAILED fix

By · Last verified

Error messageHandshakeException: Handshake error in client (OS Error: CERTIFICATE_VERIFY_FAILED

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):

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.

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:

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.

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.

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.

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

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.

FAQ

Is it OK to set badCertificateCallback to return true?

Only in a throwaway debug session. Returning true accepts any certificate, including one presented by an attacker on public Wi-Fi. If you must, guard it with kDebugMode and compare the host, but trusting your development CA through a SecurityContext is safer.

The API works in the browser and Postman but not in the Flutter app. Why?

Browsers fill in a missing intermediate certificate from cache or by downloading it, and desktop tools use the computer's trust store. Dart on Android only uses the device's system roots and the chain the server sends, so an incomplete chain or a CA trusted only on your computer fails in the app.

Why does it fail only on old Android phones?

Dart on Android uses the phone's own system CA directory. Old Android versions lack newer roots, so certificates chaining only to those roots fail. See old Android and Let's Encrypt.

I get CERTIFICATE_VERIFY_FAILED: Hostname mismatch on the Android emulator. What is wrong?

The app is probably calling https://10.0.2.2, the emulator's alias for your computer's loopback interface, while the development certificate only lists localhost. Add 10.0.2.2 as an IP SAN to the development certificate, or route the emulator to a hostname the certificate covers.

Sources

  1. Dart SDK: secure_socket_filter.cc (HandshakeException)
  2. Dart SDK: security_context_linux.cc (Android root store)
  3. Dart API: SecurityContext
  4. Dart API: HttpClient.badCertificateCallback
  5. Android Studio: Emulator network address space