How to Intercept HTTPS Traffic from Flutter Apps
Intercepting HTTP traffic from Flutter apps lets you debug API calls, inspect payloads, analyze performance, and troubleshoot network issues during development. This guide shows you how to configure a Flutter app to route traffic through Fluxzy, whether your app uses certificate pinning or not.
If you're targeting Android specifically, check out Fluxzy Connect — a companion Android app that routes traffic through a local VPN, eliminating manual proxy configuration on the device. See it in action in this video walkthrough.
What You'll Learn
- Configure a Flutter app to route traffic through Fluxzy
- Export and load the Fluxzy root CA certificate
- Handle HTTPS interception for apps with and without certificate pinning
- Code examples for
dart:io,package:http, andpackage:dio
Prerequisites:
- Fluxzy Desktop or CLI installed (see below)
- A Flutter project
Install Fluxzy CLI
If you don't have Fluxzy installed yet, the CLI is the fastest way to get started. Pick the method for your platform:
Windows (winget):
winget install Fluxzy.Fluxzy
macOS (Homebrew):
brew tap haga-rak/fluxzy
brew install fluxzy
Linux (install script):
curl -sL https://fluxzy.io/install/script/linux/latest | bash
You can also download standalone binaries from the download page or directly from GitHub releases.
Why Flutter Requires Extra Configuration
Unlike browsers, Flutter's dart:io HttpClient doesn't pick up the system proxy automatically. You must explicitly set the proxy address in your code so that HTTP requests flow through Fluxzy. For HTTPS, Fluxzy intercepts and re-signs traffic with its own root CA, so your app must also be configured to trust that certificate.
Start Fluxzy
Before running your Flutter app, start Fluxzy to listen for connections.
Using Fluxzy Desktop:
- Open Fluxzy Desktop
- Click Start Capture (or press F5)
- Default proxy address:
127.0.0.1:44344
Using Fluxzy CLI:
fluxzy start -l 127.0.0.1:44344
Export the Fluxzy CA Certificate
To handle HTTPS properly, you need Fluxzy's root CA certificate. Export it with the CLI:
fluxzy cert export fluxzy-ca.pem
This creates a PEM file containing Fluxzy's root CA. You can then bundle it in your Flutter project's assets or load it from disk during development.
Fluxzy Desktop and CLI share the same default certificate. If you set a custom root CA with fluxzy cert default, or use the built-in one, Fluxzy Desktop will use the same certificate by default. This means a certificate exported with the CLI works seamlessly when you switch to Fluxzy Desktop for capture, and vice versa — no need to export or configure twice.
Other useful certificate commands:
| Command | Description |
|---|---|
fluxzy cert export <file> |
Export the root CA to a file |
fluxzy cert check |
Verify if the certificate is trusted on your machine |
fluxzy cert install |
Install the certificate as a trusted root (requires elevation) |
fluxzy cert list |
List all root certificates |
Tip: If you install the Fluxzy certificate at the system level with fluxzy cert install, desktop Flutter apps will trust it automatically without any code changes. The code-level configuration below is still required for Android emulators and physical devices.
Determine the Proxy Address
The proxy address depends on where your Flutter app runs:
| Platform | Proxy Address | Notes |
|---|---|---|
| Desktop (Windows, macOS, Linux) | 127.0.0.1:44344 |
Direct localhost access |
| iOS Simulator | 127.0.0.1:44344 |
Shares host network |
| Android Emulator | 10.0.2.2:44344 |
10.0.2.2 maps to host loopback |
| Android Physical Device | <your-computer-ip>:44344 |
Or use Fluxzy Connect to skip proxy config |
| iOS Physical Device | <your-computer-ip>:44344 |
Device must be on the same network |
Apps Without Certificate Pinning
If your Flutter app does not implement custom certificate pinning, you only need to configure the proxy and accept Fluxzy's certificate.
dart:io (HttpClient)
import 'dart:io';
HttpClient createProxiedClient({
required String proxyHost,
required int proxyPort,
}) {
final client = HttpClient();
client.findProxy = (uri) => 'PROXY $proxyHost:$proxyPort';
// Accept Fluxzy's certificate during development
client.badCertificateCallback = (cert, host, port) => true;
return client;
}
// Usage
Future<void> main() async {
final client = createProxiedClient(
proxyHost: '127.0.0.1',
proxyPort: 44344,
);
final request = await client.getUrl(Uri.parse('https://example.com'));
final response = await request.close();
print('Status: ${response.statusCode}');
client.close();
}
package:http
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
http.Client createProxiedHttpClient({
required String proxyHost,
required int proxyPort,
}) {
final inner = HttpClient();
inner.findProxy = (uri) => 'PROXY $proxyHost:$proxyPort';
inner.badCertificateCallback = (cert, host, port) => true;
return IOClient(inner);
}
// Usage
Future<void> main() async {
final client = createProxiedHttpClient(
proxyHost: '127.0.0.1',
proxyPort: 44344,
);
final response = await client.get(Uri.parse('https://example.com'));
print('Status: ${response.statusCode}');
client.close();
}
package:dio
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
Dio createProxiedDio({
required String proxyHost,
required int proxyPort,
}) {
final dio = Dio();
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
final client = HttpClient();
client.findProxy = (uri) => 'PROXY $proxyHost:$proxyPort';
client.badCertificateCallback = (cert, host, port) => true;
return client;
},
);
return dio;
}
// Usage
Future<void> main() async {
final dio = createProxiedDio(
proxyHost: '127.0.0.1',
proxyPort: 44344,
);
final response = await dio.get('https://example.com');
print('Status: ${response.statusCode}');
dio.close();
}
Security Note: Setting badCertificateCallback to always return true disables certificate validation entirely. Use a compile-time flag or environment check to ensure this only runs in debug builds. Never ship this to production.
Apps with Certificate Pinning
If your Flutter app uses SSL certificate pinning, Fluxzy's intercepted traffic will be rejected because the proxy re-signs requests with its own CA. You need to add Fluxzy's root CA alongside your existing pinning configuration.
This section covers two pinning strategies:
| Pinning strategy | What is pinned | What to add for the proxy |
|---|---|---|
| Leaf certificate | Exact DER bytes of the server's leaf cert | The proxy's root CA as a trusted issuer |
| Root / intermediate CA | The CA certificate that issued the server cert | The proxy's root CA as an additional trusted issuer |
Important: Only add the proxy certificate in debug/development builds. Use a compile-time flag or environment check to ensure it is never included in production.
Loading the Proxy Certificate as DER Bytes
Before modifying your pinning code, load Fluxzy's root CA as Uint8List DER bytes.
Load from a file exported with fluxzy cert export:
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
Uint8List loadCertificateFromFile(String path) {
final data = File(path).readAsBytesSync();
// If starts with 0x30 (ASN.1 SEQUENCE), it's already raw DER
if (data.isNotEmpty && data[0] == 0x30) {
return Uint8List.fromList(data);
}
// Otherwise, decode as PEM / base64 text
final text = utf8.decode(data);
final cleaned = text
.replaceAll('-----BEGIN CERTIFICATE-----', '')
.replaceAll('-----END CERTIFICATE-----', '')
.replaceAll(RegExp(r'\s+'), '');
return Uint8List.fromList(base64.decode(cleaned));
}
Decode from a PEM string (e.g. hardcoded in a debug config):
import 'dart:convert';
import 'dart:typed_data';
Uint8List decodePemOrBase64(String input) {
final cleaned = input
.replaceAll('-----BEGIN CERTIFICATE-----', '')
.replaceAll('-----END CERTIFICATE-----', '')
.replaceAll(RegExp(r'\s+'), '');
return Uint8List.fromList(base64.decode(cleaned));
}
Case 1: Leaf Certificate Pinning
With leaf pinning, the app rejects any certificate whose DER bytes don't match the pinned leaf. A debugging proxy generates a different leaf for each domain, so a simple byte comparison always fails.
Strategy:
- Add the proxy's root CA to the
SecurityContextviasetTrustedCertificatesBytes - Keep
withTrustedRoots: falseso only explicitly trusted certificates are accepted - In
badCertificateCallback, accept the certificate if its DER bytes match the original pinned leaf
How it works:
- Through the proxy: the proxy-signed leaf chains to the trusted proxy CA → chain validation succeeds
- Direct connection: the real server cert fails chain validation →
badCertificateCallbackfires → DER bytes match the pinned leaf → connection allowed - Attacker cert: fails chain validation →
badCertificateCallbackfires → DER bytes don't match → connection rejected
Shared helper
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
bool _derEquals(Uint8List a, Uint8List b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
/// Creates an HttpClient that accepts:
/// - certificates signed by [proxyCaDer] (proxy-intercepted traffic), AND
/// - the exact leaf certificate matching [pinnedLeafDer] (direct traffic).
HttpClient createLeafPinningClient({
required Uint8List pinnedLeafDer,
Uint8List? proxyCaDer,
}) {
final context = SecurityContext(withTrustedRoots: false);
// Trust the proxy CA so proxy-signed certificates pass chain validation
if (proxyCaDer != null) {
final proxyPem = '-----BEGIN CERTIFICATE-----\n'
'${base64.encode(proxyCaDer)}\n'
'-----END CERTIFICATE-----\n';
context.setTrustedCertificatesBytes(utf8.encode(proxyPem));
}
final client = HttpClient(context: context);
// badCertificateCallback fires only when chain validation fails.
// If we're NOT going through the proxy, the real server cert won't
// chain to any trusted CA, so we verify it against the pinned leaf.
client.badCertificateCallback = (cert, host, port) {
return _derEquals(cert.der, pinnedLeafDer);
};
return client;
}
With package:http
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
Future<http.Response> httpLeafPinnedGet(
String url, {
required Uint8List pinnedLeafDer,
Uint8List? proxyCaDer,
}) async {
final inner = createLeafPinningClient(
pinnedLeafDer: pinnedLeafDer,
proxyCaDer: proxyCaDer,
);
final client = IOClient(inner);
try {
return await client.get(Uri.parse(url));
} finally {
client.close();
}
}
With package:dio
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
Future<Response<List<int>>> dioLeafPinnedGet(
String url, {
required Uint8List pinnedLeafDer,
Uint8List? proxyCaDer,
}) async {
final dio = Dio();
dio.options.responseType = ResponseType.bytes;
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () => createLeafPinningClient(
pinnedLeafDer: pinnedLeafDer,
proxyCaDer: proxyCaDer,
),
);
return await dio.get<List<int>>(url);
}
Case 2: Root / Intermediate CA Pinning
With CA pinning, the app trusts only a specific CA as root. The debugging proxy signs traffic with a different CA, so the connection fails.
Strategy:
Call setTrustedCertificatesBytes twice — once for the original pinned CA, and once for the proxy's root CA. The SecurityContext accumulates trusted certificates, so both CAs are accepted during chain validation.
How it works:
- Through the proxy: the proxy-signed cert chains to the proxy CA → chain validation succeeds
- Direct connection: the real server cert chains to the original pinned CA → chain validation succeeds
- Attacker cert: chains to neither CA → connection rejected
Shared helper
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
/// Creates an HttpClient that trusts [pinnedCaDer] (the original pinned CA)
/// and optionally [proxyCaDer] (the debugging proxy's root CA).
HttpClient createCaPinningClient({
required Uint8List pinnedCaDer,
Uint8List? proxyCaDer,
}) {
final context = SecurityContext(withTrustedRoots: false);
// Original pinned CA
final originalPem = '-----BEGIN CERTIFICATE-----\n'
'${base64.encode(pinnedCaDer)}\n'
'-----END CERTIFICATE-----\n';
context.setTrustedCertificatesBytes(utf8.encode(originalPem));
// Proxy CA — only in debug builds
if (proxyCaDer != null) {
final proxyPem = '-----BEGIN CERTIFICATE-----\n'
'${base64.encode(proxyCaDer)}\n'
'-----END CERTIFICATE-----\n';
context.setTrustedCertificatesBytes(utf8.encode(proxyPem));
}
final client = HttpClient(context: context);
client.badCertificateCallback = (cert, host, port) {
return false; // Reject anything that doesn't chain to a trusted CA
};
return client;
}
With package:http
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';
Future<http.Response> httpCaPinnedGet(
String url, {
required Uint8List pinnedCaDer,
Uint8List? proxyCaDer,
}) async {
final inner = createCaPinningClient(
pinnedCaDer: pinnedCaDer,
proxyCaDer: proxyCaDer,
);
final client = IOClient(inner);
try {
return await client.get(Uri.parse(url));
} finally {
client.close();
}
}
With package:dio
import 'package:dio/dio.dart';
import 'package:dio/io.dart';
Future<Response<List<int>>> dioCaPinnedGet(
String url, {
required Uint8List pinnedCaDer,
Uint8List? proxyCaDer,
}) async {
final dio = Dio();
dio.options.responseType = ResponseType.bytes;
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () => createCaPinningClient(
pinnedCaDer: pinnedCaDer,
proxyCaDer: proxyCaDer,
),
);
return await dio.get<List<int>>(url);
}
Pinning Summary
| Pinning type | What changes | Mechanism |
|---|---|---|
| Leaf | Add proxy CA to SecurityContext + keep leaf check in badCertificateCallback |
Proxy traffic passes chain validation via proxy CA; direct traffic passes via leaf DER match |
| Root / intermediate CA | Call setTrustedCertificatesBytes twice (original CA + proxy CA) |
Both CAs are trusted; chain validation accepts either |
In both cases, the proxy certificate is passed as an optional Uint8List? proxyCaDer parameter. When null (production), only the original pinning is active. When provided (debug), proxy-intercepted traffic is also accepted.
Platform-Specific Notes
Android Emulator
Android 7+ (API 24) restricts user-installed certificates by default. For debug builds, add a network security configuration:
android/app/src/debug/res/xml/network_security_config.xml:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<debug-overrides>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>
Reference it in android/app/src/debug/AndroidManifest.xml:
<application android:networkSecurityConfig="@xml/network_security_config">
Note: This configuration only applies to debug builds. Release builds are not affected.
Android Physical Device
For physical Android devices, the easiest approach is Fluxzy Connect — a companion Android app that creates a local VPN tunnel to route traffic through Fluxzy without any proxy configuration in your code. It auto-discovers your Fluxzy instance via mDNS and handles certificate installation on the device. See the video walkthrough.
If you prefer manual proxy configuration instead, set the proxy address to your computer's local IP (e.g. 192.168.1.100:44344) and start Fluxzy bound to all interfaces:
fluxzy start -l 0.0.0.0:44344
iOS Simulator
The iOS Simulator shares the host machine's network, so 127.0.0.1:44344 works directly. No additional platform configuration is needed if you handle certificates in Dart code as shown above.
iOS Physical Device
Fluxzy Connect is not available on iOS. To debug on a physical iOS device, configure the proxy address in your Dart code to point to your computer's local IP (e.g. 192.168.1.100:44344), and start Fluxzy bound to all interfaces:
fluxzy start -l 0.0.0.0:44344
The device must be on the same network as your computer.
Quick Reference
| Library | Proxy Configuration | Certificate Handling |
|---|---|---|
dart:io |
client.findProxy = (uri) => 'PROXY host:port' |
badCertificateCallback |
package:http |
Wrap HttpClient with IOClient |
Same as dart:io (via inner client) |
package:dio |
IOHttpClientAdapter(createHttpClient: ...) |
Same as dart:io (via factory) |
Troubleshooting
No Traffic in Fluxzy
Symptoms: App makes requests but Fluxzy shows no exchanges.
Solutions:
- Verify Fluxzy is running and listening on the correct port
- Check the proxy address matches your platform (use
10.0.2.2for Android emulator) - Ensure
findProxyis set on theHttpClient— Flutter doesn't use the system proxy by default - Try with an
http://URL first to verify basic proxy connectivity
CERTIFICATE_VERIFY_FAILED Errors
Symptoms: HandshakeException: CERTIFICATE_VERIFY_FAILED in the console.
Solutions:
- Verify
badCertificateCallbackis set on theHttpClient - If using certificate pinning, ensure Fluxzy's CA is added to the trusted certificates
- On Android, check that
network_security_config.xmlis in place for debug builds - Export a fresh certificate with
fluxzy cert exportand reload it in the app
Connection Refused on Android Emulator
Symptoms: SocketException: Connection refused when using 127.0.0.1.
Solutions:
- Use
10.0.2.2instead of127.0.0.1on Android emulators - Ensure Fluxzy is listening on the expected interface
- For physical devices, use the computer's network IP and start Fluxzy with
-l 0.0.0.0:44344
Proxy Works but Pinned Requests Fail
Symptoms: Non-pinned requests work through the proxy, but pinned requests are rejected.
Solutions:
- Verify the Fluxzy CA certificate is loaded correctly as DER bytes
- For leaf pinning, ensure
proxyCaDeris passed tocreateLeafPinningClient - For CA pinning, ensure
setTrustedCertificatesBytesis called for both the original CA and the proxy CA - Check that
withTrustedRoots: falseis set on theSecurityContext
Next Steps
- Core Concepts - Learn about filters, actions, and rules
- Connecting Devices - Connect physical devices for traffic capture
- Intercept Browser Traffic - Capture from Chrome, Firefox, and Edge
- Rule File Syntax - Modify traffic with YAML rules