Recently, I built a Flutter plugin for a client that enforces Certificate Transparency (CT) validation for all HTTP requests in an app. This post walks through the technical implementation, challenges faced, and lessons learned while building a production-ready security plugin.
Mobile app security is often an afterthought. Developers focus on features, user experience, and performance, while security gets pushed to “we’ll handle that later.” But here’s the thing: your app’s network traffic is constantly under threat, and standard HTTPS isn’t always enough.
Traditional HTTPS validation only checks if a certificate is signed by a trusted Certificate Authority (CA). But what if the CA is compromised? What if someone issues a fraudulent certificate for your domain?
Mobile applications face unique security challenges:
When securing mobile HTTP traffic, developers have several options:
Certificate pinning involves hardcoding specific certificates or public keys in your app. While effective, it has significant drawbacks:
This is where Certificate Transparency (CT) comes in as the optimal solution. Certificate Transparency is a security standard that requires all SSL/TLS certificates to be logged in public, append-only logs. Think of it as a public audit trail for certificates. It provides a middle ground with significant advantages:
Here’s how it works:
The benefits are clear:
While there’s been a plan to add CT support in the Dart SDK, it still hasn’t been implemented. And there are no native Flutter/Dart implementations of Certificate Transparency validation for HTTP requests. The standard http
package and popular libraries like Dio rely on some low-level HTTP request handling mechanisms under the hood, but those still do not provide CT validation.
You might think that I would just go ahead and implement a native Dart CT validation mechanism and build it into the Flutter/Dart HTTP libraries, right? Well, this would involve low-level socket programming, parsing complex data structures from certificates, and implementing the cryptographic signature verifications, a minefield of potential security holes that is better left to the mature, battle-tested native libraries.
So, I just built on top of shoulders of giants instead: I built a Flutter/Dart plugin to route HTTP requests via the underlying native iOS and Android networking stack.
Here’s where the implementation gets interesting: while both iOS and Android OS platforms support CT, the approaches differ:
iOS: Apple has built-in CT validation since iOS 9, but from iOS 12+ it was enhanced to automatically enforce CT for apps through App Transport Security policies, making implementation straightforward. In practice, you do not need to add manual CT checks in your iOS networking code – the OS will reject certs that violate Apple’s CT policy. (On older iOS versions, you could use the NSRequiresCertificateTransparency
Info.plist key to opt in.)
Android: Google provides Certificate Transparency support at the OS level through the Android Certificate Transparency Policy. However, this requires apps to explicitly opt-in to CT validation. The Network Security Configuration can declare certificateTransparency
requirements, but this only works on Android 16 (API 36) and above. On Android API 19–35, the OS ignores those CT settings entirely (no enforcement). This means for almost all current Android devices (which are below API 36), CT is effectively not enforced by the OS.
Given Android’s lack of default CT on most versions, we chose the Matt Dolan’s Certificate Transparency library for Android. Appmattus provides an OkHttp network interceptor that enforces CT checks on all API levels that OkHttp supports. This approach has several advantages:
Given that Android has native CT support through Network Security Configuration, I opted to build on top of Matt Dolan’s Certificate Transparency library instead. The reason lies in a few limitations of Android’s native implementation:
Works on Older Android Versions: It covers API 19+ (KitKat onwards), so even devices on Android 10 or 11 get CT enforcement. (By contrast, Network Security Config can only cover devices at Android 16+ .). Also, using a dedicated library ensures consistent CT behavior across different Android versions and device configurations, independent of system-level settings.
Google Chrome’s CT Policy: The Appmattus interceptor uses Google’s Chrome logs internally, ensuring SCT validation is up-to-date with the same logs used for the Google Chrome browser.
OkHttp Compatibility: Matt’s library provides CT validation for OkHttp (as well as other JVM-based HTTP libraries) through network interceptors, and since I implemented HTTP requests handling on Android using OkHttp, this is exactly what our plugin needs.
Explicit & Granular Control: Unlike Android’s opt-in system configuration, Matt’s CT library provides explicit, programmatic control over CT validation that works regardless of system settings. The library allows for custom CT policies and domain-specific rules, providing more granular control than the system-level approach.
Transparency in Code: With the library, CT validation is explicit in the code, making it clear to developers and security auditors that CT is being enforced. This can be as simple as:
OkHttpClient.Builder()
.addNetworkInterceptor(certificateTransparencyInterceptor {
// Enforce Certificate Transparency for all HTTPS domains
+"*"
})
.build()
This approach ensures that every HTTPS request made through our plugin undergoes Certificate Transparency validation, regardless of Android’s system configuration.
To integrate CT into Flutter’s HTTP workflow, I chose to build on top of the Dio library and its underlying HttpClientAdapter
interface. Dio is a very popular Dart HTTP client (with global config, interceptors, and cancellation support). The HttpClientAdapter
interface allows us to intercept low-level requests and reroute them through native code.
This decision was driven by several factors:
Dio’s Market Dominance: Dio is the most popular HTTP client library in the Flutter ecosystem. According to pub.dev metrics, it has significantly higher usage than alternatives like the standard http
package or chopper
. This means maximum impact for the security enhancement.
Mature Interface: The HttpClientAdapter
interface provides a simple abstraction:
abstract class HttpClientAdapter {
Future<ResponseBody> fetch(
RequestOptions options,
Stream<List<int>>? requestStream,
Future<void>? cancelFuture,
);
void close({bool force = false});
}
Drop-in Replacement: Existing Dio users can swap adapters without code changes:
// Before: Standard Dio
final dio = Dio();
// After: With Certificate Transparency
final ctPlugin = SecureHttpClientPlugin();
final dio = Dio();
dio.httpClientAdapter = ctPlugin.createHttpClientAdapter();
// All requests now have CT validation
However, this architectural decision comes with trade-offs:
http
or other clients can’t benefit without migrationFor teams not using Dio, this plugin won’t work out of the box. Future iterations could include:
The plugin follows a layered architecture:
Let’s look at how this flows through the code:
// lib/secure_http_client.dart
class SecureHttpClientPlugin {
HttpClientAdapter createHttpClientAdapter() {
return _SecureHttpClientAdapter();
}
}
class _SecureHttpClientAdapter implements HttpClientAdapter {
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<List<int>>? requestStream,
Future<void>? cancelFuture,
) {
return SecureHttpClientPlatform.instance
.fetch(options, requestStream, cancelFuture);
}
}
The adapter delegates to the platform interface, which handles the method channel communication:
// lib/secure_http_client_method_channel.dart
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<List<int>>? requestStream,
Future<void>? cancelFuture,
) async {
final cancellationId = const Uuid().v4();
final Map<String, dynamic> args = {
'cancellationId': cancellationId,
'url': options.uri.toString(),
'method': options.method,
'headers': options.headers,
'body': options.data != null ? jsonEncode(options.data) : null,
'connectTimeout': options.connectTimeout?.inMilliseconds,
'readTimeout': options.receiveTimeout?.inMilliseconds,
'writeTimeout': options.sendTimeout?.inMilliseconds,
'followRedirects': options.followRedirects,
};
// Handle Dio's cancellation mechanism
if (cancelFuture != null) {
cancelFuture.whenComplete(() {
methodChannel.invokeMethod('cancelRequest', {'cancellationId': cancellationId});
});
}
try {
final dynamic rawResult = await methodChannel.invokeMethod(
'sendHttpRequest',
args,
);
// Process response...
return ResponseBody.fromBytes(bodyBytes, statusCode, headers: headers);
} on PlatformException catch (e) {
throw _mapPlatformExceptionToDioException(e, options);
}
}
Our cancellation mechanism fully aligns with Dio’s built-in cancellation system. When Dio creates a request with a CancelToken
, it passes a cancelFuture
to the HttpClientAdapter.fetch()
method. Our implementation properly handles this:
// Dio's cancellation flows through the cancelFuture parameter
if (cancelFuture != null) {
cancelFuture.whenComplete(() {
// Notify native side to cancel the request
methodChannel.invokeMethod('cancelRequest', {'cancellationId': cancellationId});
});
}
This means existing Dio cancellation code works seamlessly:
final cancelToken = CancelToken();
// Start a request
final responseFuture = dio.get('/api/data', cancelToken: cancelToken);
// Cancel it later
cancelToken.cancel('User cancelled');
The plugin automatically handles the cancellation by tracking requests with unique identifiers and cancelling the appropriate native HTTP calls.
As mentioned in earlier, for Android I used OkHttp with the AppMattus Certificate Transparency library. This combination provides robust, explicit CT validation.
// android/src/main/kotlin/.../SecureHttpClientPlugin.kt
class SecureHttpClientPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
companion object {
private val defaultClient by lazy {
OkHttpClient.Builder()
.addNetworkInterceptor(certificateTransparencyInterceptor {
// Enforce Certificate Transparency for all HTTPS domains
+"*"
})
.build()
}
}
private fun handleSendHttpRequest(call: MethodCall, result: Result) {
val url = call.argument<String>("url") ?: return result.error("INVALID_ARGUMENT", "URL required", null)
val cancellationId = call.argument<String>("cancellationId") ?: return result.error("INVALID_ARGUMENT", "cancellationId required", null)
val method = call.argument<String>("method") ?: "GET"
val headers = call.argument<Map<String, String>>("headers") ?: emptyMap()
val body = call.argument<String>("body")
// Configure client with timeouts
val clientBuilder = httpClient.newBuilder()
call.argument<Int>("connectTimeout")?.let {
clientBuilder.connectTimeout(it.toLong(), TimeUnit.MILLISECONDS)
}
call.argument<Int>("readTimeout")?.let {
clientBuilder.readTimeout(it.toLong(), TimeUnit.MILLISECONDS)
}
val client = clientBuilder.build()
val request = createHttpRequest(url, method, headers, body)
val httpCall = client.newCall(request)
// Track for cancellation
activeRequests[cancellationId] = httpCall
// Execute request on background thread
httpCall.enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
activeRequests.remove(cancellationId)
val responseBody = response.body?.bytes() ?: ByteArray(0)
val responseHeaders = response.headers.toMultimap()
val base64Body = Base64.encode(responseBody)
val responseMap = mapOf(
"cancellationId" to cancellationId,
"statusCode" to response.code,
"reasonPhrase" to response.message,
"headers" to responseHeaders,
"body" to base64Body
)
// Return result on main thread
mainThreadExecutor.execute { result.success(responseMap) }
}
override fun onFailure(call: Call, e: IOException) {
activeRequests.remove(cancellationId)
val errorCode = mapErrorToCode(e)
// Return error on main thread
mainThreadExecutor.execute {
result.error(errorCode, e.message, mapOf("cancellationId" to cancellationId))
}
}
})
}
}
The key part is the Certificate Transparency interceptor configuration:
.addNetworkInterceptor(certificateTransparencyInterceptor {
// Enforce CT for all HTTPS domains
+"*"
})
This ensures every HTTPS request is explicitly validated against CT logs before proceeding. Note how the HTTP request execution happens on background threads via OkHttp’s enqueue()
method, but results are returned to Flutter on the main thread using mainThreadExecutor
.
iOS implementation leverages the system’s built-in CT support:
// ios/Classes/SecureHttpClientPlugin.swift
public class SecureHttpClientPlugin: NSObject, FlutterPlugin, URLSessionDelegate {
private func executeHttpRequest(with requestData: RequestData, result: @escaping FlutterResult) {
let request = createURLRequest(from: requestData)
let session = createURLSession(with: requestData)
let cancellationId = requestData.cancellationId
// Execute request on background thread
var task: URLSessionDataTask!
task = session.dataTask(with: request) { [weak self] data, response, error in
guard let self else { return }
defer {
self.activeRequestsQueue.async {
self.activeRequests.removeValue(forKey: cancellationId)
}
}
if let error = error as NSError? {
let errorCode = self.mapErrorToCode(error)
// Return error on main thread
self.returnResult(result, FlutterError(
code: errorCode,
message: error.localizedDescription,
details: ["cancellationId": cancellationId]
))
return
}
guard let httpResponse = response as? HTTPURLResponse,
let data = data else {
// Return error on main thread
self.returnResult(result, FlutterError(
code: "INVALID_RESPONSE",
message: "No valid response received",
details: ["cancellationId": cancellationId]
))
return
}
let responseHeaders = self.extractHeaders(httpResponse.allHeaderFields)
let base64Body = data.base64EncodedString()
let responseMap: [String: Any] = [
"cancellationId": cancellationId,
"statusCode": httpResponse.statusCode,
"reasonPhrase": HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode),
"headers": responseHeaders,
"body": base64Body,
]
// Return success on main thread
self.returnResult(result, responseMap)
}
activeRequestsQueue.async {
self.activeRequests[cancellationId] = task
}
task.resume()
}
private func createURLSession(with requestData: RequestData) -> URLSession {
let sessionConfig = URLSessionConfiguration.default
// Configure timeouts (convert from milliseconds to seconds)
sessionConfig.timeoutIntervalForRequest = requestData.connectTimeout / 1000.0
sessionConfig.timeoutIntervalForResource = requestData.readTimeout / 1000.0
// iOS handles Certificate Transparency automatically for iOS 12+
// through system security policies
return URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil)
}
/// Ensures results are returned to Flutter on the main thread
private func returnResult(_ result: @escaping FlutterResult, _ value: Any) {
DispatchQueue.main.async {
result(value)
}
}
}
The elegance of the iOS implementation is that Certificate Transparency validation happens automatically at the system level. I don’t need to add explicit CT checking, iOS handles it for us through App Transport Security.
Notice how both platforms handle threading properly: HTTP requests execute on background threads (OkHttp’s thread pool on Android, URLSession’s background queue on iOS), but results are always returned to Flutter on the main thread to ensure proper UI updates.
One of the trickiest parts was creating consistent error handling across platforms. Native HTTP libraries throw different exceptions, but Dio users expect consistent DioException
types.
The solution involved mapping platform-specific errors to standardized codes:
// Dart error mapping
DioException _mapPlatformExceptionToDioException(
PlatformException platformException,
RequestOptions options,
) {
final String code = platformException.code;
DioExceptionType dioExceptionType;
switch (code) {
case 'CONNECTION_TIMEOUT':
dioExceptionType = DioExceptionType.connectionTimeout;
break;
case 'RECEIVE_TIMEOUT':
dioExceptionType = DioExceptionType.receiveTimeout;
break;
case 'BAD_CERTIFICATE':
dioExceptionType = DioExceptionType.badCertificate;
break;
case 'CANCELLED':
dioExceptionType = DioExceptionType.cancel;
break;
case 'CONNECTION_ERROR':
dioExceptionType = DioExceptionType.connectionError;
break;
default:
dioExceptionType = DioExceptionType.unknown;
break;
}
return DioException(
requestOptions: options,
type: dioExceptionType,
message: platformException.message ?? 'Network error occurred',
error: platformException,
);
}
On Android, I map OkHttp exceptions:
private fun mapErrorToCode(e: Exception): String {
if (e is IOException && e.message?.lowercase() == "canceled") {
return "CANCELLED"
}
return when (e) {
is SSLException -> "BAD_CERTIFICATE"
is SocketTimeoutException -> "CONNECTION_TIMEOUT"
is InterruptedIOException -> "RECEIVE_TIMEOUT"
is UnknownHostException -> "CONNECTION_ERROR"
is ConnectException -> "CONNECTION_ERROR"
is java.io.EOFException -> "CONNECTION_ERROR"
is IOException -> "UNKNOWN_ERROR"
else -> "UNKNOWN_ERROR"
}
}
On iOS, I map NSURLError codes:
private func mapErrorToCode(_ error: NSError) -> String {
switch error.domain {
case NSURLErrorDomain:
switch error.code {
case NSURLErrorTimedOut:
return "CONNECTION_TIMEOUT"
case NSURLErrorCannotFindHost,
NSURLErrorCannotConnectToHost,
NSURLErrorDNSLookupFailed:
return "CONNECTION_ERROR"
case NSURLErrorSecureConnectionFailed,
NSURLErrorServerCertificateUntrusted:
return "BAD_CERTIFICATE"
case NSURLErrorCancelled:
return "CANCELLED"
default:
return "UNKNOWN_ERROR"
}
default:
return "UNKNOWN_ERROR"
}
}
Here’s how the plugin works in practice:
class ApiService {
late final Dio _dio;
ApiService() {
final ctPlugin = SecureHttpClientPlugin();
_dio = Dio();
_dio.httpClientAdapter = ctPlugin.createHttpClientAdapter();
// Configure timeouts
_dio.options.connectTimeout = const Duration(seconds: 30);
_dio.options.receiveTimeout = const Duration(seconds: 30);
}
Future<Map<String, dynamic>> fetchUserData(String userId) async {
try {
final response = await _dio.get('/api/users/$userId');
return response.data;
} on DioException catch (e) {
switch (e.type) {
case DioExceptionType.badCertificate:
// Certificate Transparency validation failed
throw SecurityException('Certificate validation failed - possible security issue');
case DioExceptionType.connectionTimeout:
throw NetworkException('Connection timeout');
case DioExceptionType.connectionError:
throw NetworkException('Connection error');
default:
throw NetworkException('Request failed: ${e.message}');
}
}
}
}
The beauty is that once configured, Certificate Transparency validation happens automatically for all requests. Developers don’t need to think about it - the plugin handles CT validation transparently.
Testing a networking plugin presents unique challenges. You need to test both success and failure scenarios without making tests brittle or dependent on external services.
Our approach combined unit tests with mocked dependencies and integration tests with real network calls:
// Android unit test
@Test
fun `sendHttpRequest should configure OkHttpClient with correct timeouts`() {
val call = mockMethodCall(mapOf(
"cancellationId" to "test-123",
"url" to "https://example.com",
"method" to "GET",
"connectTimeout" to 5000,
"readTimeout" to 10000
))
plugin.onMethodCall(call, mockResult)
verify(mockClientBuilder).connectTimeout(5000, TimeUnit.MILLISECONDS)
verify(mockClientBuilder).readTimeout(10000, TimeUnit.MILLISECONDS)
}
// iOS integration test
func testRealNetworkRequest() {
let expectation = XCTestExpectation(description: "Network request completes")
let args: [String: Any] = [
"cancellationId": "test-request-123",
"url": "https://httpbin.org/get",
"method": "GET"
]
plugin.sendHttpRequest(args: args) { result in
switch result {
case .success(let response):
XCTAssertEqual(response["statusCode"] as? Int, 200)
case .failure(let error):
if error.localizedDescription.contains("network") {
XCTSkip("Network unavailable for integration test")
} else {
XCTFail("Unexpected error: \(error)")
}
}
expectation.fulfill()
}
wait(for: [expectation], timeout: 30.0)
}
Each platform had unique characteristics:
Certificate Transparency validation adds overhead to each request. I had to balance security with performance by:
Future iterations could address these limitations:
http
, chopper
, and other popular Flutter/Dart HTTP libraries.The current architecture provides several extension points:
This plugin was developed as part of a client project, which means the complete source code isn’t publicly available. The code snippets in this post have been adapted and anonymized to demonstrate the implementation concepts while protecting the client’s intellectual property.
For teams interested in implementing similar functionality, the architectural patterns and implementation strategies outlined here provide a solid foundation for building your own Certificate Transparency validation solution.
Building this Certificate Transparency plugin reinforced several key principles:
For Flutter developers working on security-sensitive applications, Certificate Transparency provides an additional layer of protection against malicious certificates and man-in-the-middle attacks. While this implementation is Dio-specific, the concepts and patterns demonstrated here can be adapted for other HTTP clients or used as inspiration for building more generic solutions.