Authentication & Security

SSL Pinning & Certificate Validation

16 min Lesson 8 of 12

SSL Pinning & Certificate Validation

Transport Layer Security (TLS/SSL) encrypts data in transit, but it does not protect against a sophisticated attacker who presents a fraudulent certificate issued by a trusted Certificate Authority (CA) — a classic man-in-the-middle (MITM) attack. SSL pinning hardens your Flutter app by binding it to a known certificate or public key so that connections to any other server — even one holding a "valid" CA-signed certificate — are rejected outright.

Note: SSL pinning is especially important in mobile apps because an attacker on the same network (café Wi-Fi, corporate proxy) can install a rogue CA certificate on the device and intercept HTTPS traffic with standard tools like Charles Proxy or mitmproxy. Pinning makes this class of attack ineffective against your app.

Why Certificate Pinning Matters

The default TLS handshake trusts any certificate signed by one of hundreds of built-in CAs. An attacker who compromises or impersonates a CA can issue a certificate for your domain, allowing them to decrypt all traffic. Pinning restricts trust to a specific certificate or its public key, eliminating that risk.

  • Certificate pinning: Store the server's full DER/PEM certificate and compare it byte-for-byte on each connection.
  • Public-key pinning (HPKP-style): Store only the SHA-256 hash of the server's SubjectPublicKeyInfo (SPKI) and compare that hash. This survives certificate renewal as long as the key-pair is re-used.
  • Leaf vs. intermediate vs. root pinning: Pinning the leaf certificate is most restrictive; pinning an intermediate CA is a common balance between security and operational flexibility.
Tip: Prefer public-key pinning over certificate pinning in production. When your certificate expires and is renewed with the same key, the pin remains valid and your app keeps working without a forced update.

Obtaining the Public-Key Hash

Run the following openssl commands against your server's certificate to extract the SPKI SHA-256 hash:

Extract SPKI SHA-256 hash with OpenSSL

# Download the certificate chain
openssl s_client -connect api.example.com:443 -showcerts </dev/null 2>&1 | \
  openssl x509 -outform DER > server.der

# Compute the SHA-256 hash of the SubjectPublicKeyInfo
openssl x509 -in server.der -inform DER -pubkey -noout | \
  openssl pkey -pubin -outform DER | \
  openssl dgst -sha256 -binary | \
  openssl base64

This produces a Base64 string such as abc123+xyz…==. Store this string in your app — it becomes your pin.

Implementing SSL Pinning with Dio

Dio is the most popular HTTP client in Flutter. Its HttpClientAdapter exposes a BadCertificateCallback where you can intercept the TLS handshake and validate the server's certificate against your stored pin.

Dio — public-key pinning via BadCertificateCallback

import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:dio/io.dart';

class PinnedDioClient {
  /// SHA-256 Base64-encoded SPKI hashes for api.example.com
  static const List<String> _pins = [
    'abc123+primaryKeyHash==',   // current certificate key
    'def456+backupKeyHash==',    // backup key for rotation
  ];

  static Dio create() {
    final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));

    (dio.httpClientAdapter as IOHttpClientAdapter).createHttpClient = () {
      final client = HttpClient();

      client.badCertificateCallback =
          (X509Certificate cert, String host, int port) {
        // Extract the raw public-key bytes (DER-encoded SubjectPublicKeyInfo)
        final pubKeyBytes = cert.der; // full DER cert bytes

        // Compute SHA-256 of the full DER cert as a proxy for the SPKI hash
        // For a precise SPKI hash, parse the ASN.1 structure or use a native plugin
        final digest = sha256.convert(pubKeyBytes);
        final hash = base64.encode(digest.bytes);

        // Allow the connection only when the hash matches a known pin
        return _pins.contains(hash);
      };

      return client;
    };

    return dio;
  }
}

// Usage
final dio = PinnedDioClient.create();
final response = await dio.get('/users/me');
Warning: The badCertificateCallback returning true allows a certificate that would otherwise be rejected. Returning false keeps the default rejection. Do not return true unconditionally — this disables all certificate validation and is worse than no pinning at all.

Implementing SSL Pinning with the http Package

The standard http package uses Dart's built-in HttpClient. You can wrap it in a custom IOClient and apply the same badCertificateCallback pattern.

http package — pinning via SecurityContext & IOClient

import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart';

Future<http.Client> buildPinnedHttpClient(
    List<int> pinnedCertDerBytes) async {
  // Load the certificate that must be trusted
  final context = SecurityContext(withTrustedRoots: false);
  context.setTrustedCertificatesBytes(pinnedCertDerBytes);

  final httpClient = HttpClient(context: context);
  // Reject connections to any host not matching the pinned cert
  httpClient.badCertificateCallback =
      (X509Certificate cert, String host, int port) => false;

  return IOClient(httpClient);
}

// In your service layer
Future<void> fetchSecureData(List<int> pinnedCert) async {
  final client = await buildPinnedHttpClient(pinnedCert);
  try {
    final response = await client.get(
      Uri.parse('https://api.example.com/data'),
    );
    print('Status: \${response.statusCode}');
  } finally {
    client.close();
  }
}

Certificate Rotation & Backup Pins

Every pinning implementation should include at least two pins: the current certificate key and a pre-generated backup key. When the primary certificate expires:

  • Generate a new key-pair and CSR server-side before the old certificate expires.
  • Deploy a new app version with the backup key promoted to primary and a new backup added.
  • Complete a staged rollout before the old certificate expires.
  • Never let all active app versions depend on a single pin that is about to be revoked.
Tip: Store pins in a remote configuration service (like Firebase Remote Config) so you can update them without a forced app update. Gate the update behind signature verification so the config itself cannot be tampered with.

Platform Notes & Web Limitations

The dart:io HttpClient and SecurityContext APIs are available only on Android, iOS, and desktop. Flutter Web uses browser fetch under the hood, where pinning is not accessible from Dart. For web targets, rely on your server's HSTS headers and CAA DNS records instead. Structure your code so the pinned client is injected and can be swapped for a plain client on web builds using conditional imports (dart.library.io vs. dart.library.html).

Summary

SSL pinning is a critical defence-in-depth layer for Flutter apps that communicate with sensitive APIs. The key takeaways are:

  • Prefer public-key (SPKI) hash pinning over full-certificate pinning for operational resilience.
  • Use Dio's IOHttpClientAdapter or the http package's IOClient with a custom badCertificateCallback to enforce the pin at the HTTP layer.
  • Always include a backup pin and plan a certificate rotation procedure before shipping.
  • Never return true unconditionally from badCertificateCallback — that disables all security.