in

Fix CERT_HAS_EXPIRED Error in Flutter Local API Calls

Fix CERT_HAS_EXPIRED Error in Flutter Local API Calls
Fix CERT_HAS_EXPIRED Error in Flutter Local API Calls

If you’re staring at a CERT_HAS_EXPIRED error or a HandshakeException the moment your Flutter app tries to hit your local API, welcome to a very specific kind of pain. This almost always happens when you’re testing against https://localhost or a self-signed cert on your dev machine, and it has basically nothing to do with your certificate actually being expired most of the time — which is the confusing part.

Let’s sort out why this happens and what actually fixes it, because “just ignore SSL errors” advice floating around forums will get you in trouble later.

Quick Answer

  • Check your device or emulator’s system date/time first — this causes false “expired” errors constantly
  • If using a self-signed cert, install it properly on the emulator/device trust store, don’t just bypass validation
  • On Android, add a network_security_config.xml entry for your dev domain
  • On iOS, check your Info.plist for App Transport Security (ATS) exceptions
  • Never ship a badCertificateCallback that returns true unconditionally to production — that’s a security hole, not a fix

Why This Happens

There’s more than one cause here, and from what I’ve seen, people usually jump to “my cert is broken” when it’s often something else entirely.

1. Device or emulator clock is wrong. This is the one nobody checks first and probably should. Android emulators especially can drift or reset to a wrong date after a snapshot restore. If the emulator thinks it’s 2019, your perfectly valid 2026 cert looks expired to it. So before touching any code, check the date on the actual device or emulator — not your host machine.

2. Self-signed certs aren’t trusted by the mobile OS. Your browser or curl might trust a self-signed cert because you added an exception once. The Android or iOS trust store doesn’t know anything about that. Flutter’s underlying HttpClient (via dart:io) validates against the system trust store, and a self-signed cert isn’t in it by default.

3. Using localhost from an emulator instead of the host-mapped address. This one trips up almost everyone at some point. On the Android emulator, localhost refers to the emulator itself, not your dev machine. You need 10.0.2.2 instead. iOS simulators are different — they can actually use localhost directly since they share the host network stack. Mixing these up gives connection errors that sometimes get misreported as cert issues depending on what’s listening on that port instead.

4. Certificate chain is incomplete. If you’re using a cert from a local CA (like mkcert) but only installed the leaf certificate without the root CA on the device, the chain validation fails. It’s not “expired,” it’s “untrusted,” but the error messages Flutter surfaces aren’t always precise about which.

5. Actual expiration, sometimes. Yes, sometimes the cert really is expired. Dev certs from tools like mkcert or self-issued OpenSSL certs often get generated with short validity windows and people forget to regenerate them after a few months.

Common Scenarios

  • Android emulator hitting a local Node/Express/Django server — usually the 10.0.2.2 vs localhost issue, or clock drift after emulator snapshot restore
  • iOS Simulator with a self-signed cert — usually ATS blocking the connection outright before it even gets to a handshake
  • Physical device on the same Wi-Fi network — usually the cert simply isn’t trusted on that device at all, since it’s not localhost anymore, it’s a real network address
  • CI/CD emulator runs — clock sync issues are more common here since CI emulators often boot from cached snapshots

Step-by-Step Fixes

Step 1: Check the Device Clock First

Seriously, do this before anything else. On an Android emulator, open Settings → System → Date & Time and confirm it’s correct and auto-syncing. If it’s off, that’s your whole problem, and you just saved yourself an hour.

Step 2: Confirm You’re Using the Right Host Address

For Android emulator:

dart

final baseUrl = 'https://10.0.2.2:5000';

For iOS simulator:

dart

final baseUrl = 'https://localhost:5000';

For a physical device on the same network, use your machine’s LAN IP, like https://192.168.1.42:5000, and make sure your local server is actually bound to 0.0.0.0 and not just 127.0.0.1 — a mistake that’s easy to make and looks like a cert problem when it’s really a “nothing’s listening there” problem.

Step 3: Generate a Proper Local Dev Certificate

Skip raw OpenSSL self-signed certs if you can. Use mkcert instead — it creates a local CA and installs it into your system trust store, which makes debugging way less painful:

bash

mkcert -install
mkcert localhost 127.0.0.1 10.0.2.2

This gives you a cert your machine trusts, but you still need to get the CA onto the mobile device or emulator (see Step 4).

Step 4: Install the CA Certificate on Android

Push the mkcert root CA to the emulator:

bash

adb push rootCA.pem /sdcard/

Then on the emulator: Settings → Security → Encryption & Credentials → Install a certificate → CA certificate, and select the file. Android 7+ also requires a network_security_config.xml entry pointing to your cert if you want app-level trust without relying purely on the system store:

xml

<network-security-config>
    <domain-config>
        <domain includeSubdomains="true">10.0.2.2</domain>
        <trust-anchors>
            <certificates src="@raw/rootca"/>
        </trust-anchors>
    </domain-config>
</network-security-config>

Reference it in AndroidManifest.xml under application:

xml

android:networkSecurityConfig="@xml/network_security_config"

Step 5: Handle iOS ATS Exceptions

For iOS, add an ATS exception in Info.plist scoped to your dev domain only — not globally:

xml

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSExceptionDomains</key>
    <dict>
        <key>localhost</key>
        <dict>
            <key>NSExceptionAllowsInsecureHTTPLoads</key>
            <true/>
            <key>NSIncludesSubdomains</key>
            <true/>
        </dict>
    </dict>
</dict>

Scoping it to just localhost matters. A blanket ATS bypass across all domains is a habit that tends to sneak into production builds and nobody notices until an app review flags it.

Technical Comparison: Fixes by Platform

PlatformRoot Cause UsuallyFixNotes
Android EmulatorWrong host address or untrusted CAUse 10.0.2.2, install root CAClock drift also common here
iOS SimulatorATS blocking or untrusted certAdd scoped ATS exception, install CA in Keychainlocalhost works directly, unlike Android
Physical Android deviceCert not trusted, wrong network IPInstall CA manually, use LAN IPSome Android versions restrict user CAs for apps targeting API 24+
Physical iOS deviceSame as simulator, plus network isolationSame ATS fix, check Wi-Fi firewallCorporate networks sometimes block the port entirely

What Actually Worked For Me

My first instinct was to just override badCertificateCallback and move on with my life. It worked instantly, which felt great for about a day, until I hit a different, weirder connection issue and realized I’d been debugging blind because the cert bypass was hiding a real network config problem underneath it the whole time.

So I backed that out and actually installed the CA properly using mkcert. That took maybe 15 minutes and fixed it cleanly, no bypass needed. The clock thing wasn’t my issue that time, though it has been on a different project — an old CI runner had its snapshot clock frozen at some date months in the past, and every test run failed with the exact same CERT_HAS_EXPIRED message until someone (not me, a coworker) mentioned offhand that emulator snapshots don’t always resync time on boot. That one wasn’t in any doc I found.

Advanced Fixes and Edge Cases

Inspect the actual TLS handshake with OpenSSL. From your dev machine, you can confirm what the server is actually presenting:

bash

openssl s_client -connect localhost:5000 -showcerts

Check the notAfter field in the output. If it says the cert is valid but the app still throws CERT_HAS_EXPIRED, the problem is almost certainly the device’s clock, not the cert.

Use SecurityContext in Dart for controlled trust, not blanket bypass. Instead of disabling validation entirely, you can load your specific CA into a SecurityContext and pass it to your HttpClient:

dart

final context = SecurityContext(withTrustedRoots: true)
  ..setTrustedCertificates('assets/rootCA.pem');
final client = HttpClient(context: context);

This keeps validation on for everything else while trusting your specific dev CA — a much safer middle ground than a global bypass.

Check for a proxy or VPN intercepting traffic. If you’re on a corporate VPN or using something like Charles Proxy for debugging, its own cert can conflict with your app’s expected cert chain. Temporarily disabling the proxy is a quick way to rule this out.

Prevention Tips

  • Keep dev certs generated with mkcert and regenerate periodically — check the actual expiration date instead of assuming
  • Never leave a global ATS bypass or unconditional badCertificateCallback in code that could ship to production
  • Document the 10.0.2.2 vs localhost distinction somewhere your team will actually see it — this gets rediscovered by every new hire otherwise
  • Consider using a tunneling tool like ngrok for testing against a real HTTPS endpoint instead of fighting local certs entirely, especially for physical device testing

FAQ

Is CERT_HAS_EXPIRED always about an actual expired certificate? No, and honestly it’s more often not. Device clock issues and untrusted CAs both throw the same message.

Can I just disable SSL verification for testing? You can, but don’t leave it in. It hides other problems and it’s an easy thing to forget before a release build.

Why does localhost work on iOS Simulator but not Android Emulator? The iOS Simulator shares your Mac’s network stack directly, so localhost really is your machine. The Android Emulator runs its own virtual network, so localhost refers to itself.

Does mkcert work without an internet connection? Yes, it generates certs locally using its own local CA — no internet needed after initial install.

Will this happen with HTTP instead of HTTPS? No, cert errors are HTTPS-specific by nature. HTTP has other problems (like Android’s cleartext traffic blocking by default), but not this one.

Editor’s Opinion

Most of the time this isn’t really a cert problem, it’s a “the emulator’s clock is wrong” or “you typed localhost when you needed 10.0.2.2” problem wearing a cert error’s name tag. Check the boring stuff first — date, host address — before touching any trust store config. And don’t leave a bypass callback in your code just because it made the error go away for now; that’s a problem for future you, and future you will not be happy about it.

Written by ugur

Ugur is an editor and writer at (NSF Tech), specializing in technology and Windows. He produces in-depth, well-researched, and reliable stories with a strong focus on Windows, emerging technologies, digital culture, cybersecurity, AI developments, and innovative solutions shaping the future. His work aims to inform, inspire, and engage readers worldwide with accurate reporting and a clear editorial voice.

Contact: [email protected]