Certificate Pinning
Certificate pinning is a critical security technique that prevents man-in-the-middle (MITM) attacks by validating that the server's certificate matches a known good certificate or public key. This section covers comprehensive certificate pinning strategies for mobile applications.
Quick Decision
| Situation | Use Pinning | Watch Out |
|---|---|---|
| Highly sensitive API | Yes | Backup pin is required |
| Certificates change often | Public key pin | Rotation plan is needed |
| Development environment | Separate pin/config | Production pin must not be weakened |
| Low-risk public content | TLS may be enough | Run a YAGNI check |
Production Checklist
- Problem: Which MITM risk does pinning reduce?
- Solution: Are pin type, backup pin, rotation, kill switch, and error UX clear?
- Trade-off: MITM risk drops; a wrong pin can break all networking.
- Failure mode: Expired certs, pin mismatch, captive portals, and clock skew should be handled.
- Measurement: Track pin failures, TLS errors, affected versions, and endpoint availability.
- Security/cost: Pin bypass must not ship to production even for debug; rotation needs operational cost.
Understanding Certificate Pinning
Certificate pinning works by embedding expected certificate information in the mobile application and validating server certificates against this known good information during SSL/TLS handshake.
Types of Certificate Pinning
- Certificate Pinning: Pin the entire certificate
- Public Key Pinning: Pin only the public key (recommended)
- CA Pinning: Pin the Certificate Authority
- Subject Public Key Info (SPKI) Pinning: Pin the SPKI hash
Android Certificate Pinning
Using OkHttp CertificatePinner
kotlin
class NetworkSecurityManager {
// Production certificate pins
private val certificatePinner = CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") // Current cert
.add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // Backup cert
.add("*.example.com", "sha256/CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=") // Wildcard
.build()
// Development/Debug configuration (no pinning)
private val debugCertificatePinner = CertificatePinner.Builder().build()
fun createSecureOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.certificatePinner(if (BuildConfig.DEBUG) debugCertificatePinner else certificatePinner)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.addInterceptor(createLoggingInterceptor())
.build()
}
// Generate certificate pins programmatically
fun generateCertificatePin(hostname: String): String {
val client = OkHttpClient.Builder()
.certificatePinner(CertificatePinner.Builder().build())
.build()
val request = Request.Builder()
.url("https://$hostname")
.build()
try {
client.newCall(request).execute()
} catch (e: SSLPeerUnverifiedException) {
// Extract pins from exception message
val pins = e.message?.let { extractPinsFromMessage(it) }
return pins ?: "Unable to extract pins"
}
return "Connection succeeded without pinning"
}
private fun extractPinsFromMessage(message: String): String {
// Parse the exception message to extract certificate pins
val pinPattern = Regex("sha256/([A-Za-z0-9+/=]+)")
return pinPattern.findAll(message)
.map { it.value }
.joinToString("\n")
}
}Network Security Configuration (Android 7.0+)
xml
<!-- res/xml/network_security_config.xml -->
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">api.example.com</domain>
<pin-set>
<pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
<pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
</pin-set>
<trust-anchors>
<certificates src="system"/>
</trust-anchors>
</domain-config>
<!-- Debug configuration -->
<debug-overrides>
<trust-anchors>
<certificates src="user"/>
<certificates src="system"/>
</trust-anchors>
</debug-overrides>
</network-security-config>kotlin
// Custom TrustManager for advanced certificate validation
class CustomCertificatePinner : X509TrustManager {
private val systemTrustManager: X509TrustManager
private val pinnedCertificates: Set<String>
init {
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
trustManagerFactory.init(null as KeyStore?)
systemTrustManager = trustManagerFactory.trustManagers
.filterIsInstance<X509TrustManager>()
.first()
pinnedCertificates = setOf(
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", // Production cert
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=" // Backup cert
)
}
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
systemTrustManager.checkClientTrusted(chain, authType)
}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
// First, validate with system trust manager
systemTrustManager.checkServerTrusted(chain, authType)
// Then, check certificate pinning
val pinnedCertFound = chain.any { cert ->
val publicKeyHash = hashPublicKey(cert.publicKey)
pinnedCertificates.contains(publicKeyHash)
}
if (!pinnedCertFound) {
throw CertificateException("Certificate pinning failure: No pinned certificate found in chain")
}
}
override fun getAcceptedIssuers(): Array<X509Certificate> {
return systemTrustManager.acceptedIssuers
}
private fun hashPublicKey(publicKey: PublicKey): String {
val spkiBytes = publicKey.encoded
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(spkiBytes)
return Base64.encodeToString(hash, Base64.NO_WRAP)
}
}iOS Certificate Pinning
Using URLSession with Certificate Pinning
swift
import Network
import CryptoKit
class CertificatePinningManager: NSObject {
private let pinnedCertificates: Set<String>
private let pinnedPublicKeys: Set<String>
init(certificatePins: Set<String> = [], publicKeyPins: Set<String> = []) {
self.pinnedCertificates = certificatePins
self.pinnedPublicKeys = publicKeyPins
super.init()
}
// Create URLSession with certificate pinning
func createSecureURLSession() -> URLSession {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 60
return URLSession(
configuration: configuration,
delegate: self,
delegateQueue: nil
)
}
}
extension CertificatePinningManager: URLSessionDelegate {
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
// Only handle server trust challenges
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
// Get server trust
guard let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Validate certificate pinning
if validateCertificatePinning(serverTrust: serverTrust) {
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
private func validateCertificatePinning(serverTrust: SecTrust) -> Bool {
// First, evaluate the trust using system validation
var result: SecTrustResultType = .invalid
let status = SecTrustEvaluate(serverTrust, &result)
guard status == errSecSuccess else {
return false
}
// System validation passed, now check our pins
let certificateCount = SecTrustGetCertificateCount(serverTrust)
for i in 0..<certificateCount {
guard let certificate = SecTrustGetCertificateAtIndex(serverTrust, i) else {
continue
}
// Check certificate pinning
if !pinnedCertificates.isEmpty {
let certificateData = SecCertificateCopyData(certificate)
let certificateHash = hashCertificate(data: certificateData)
if pinnedCertificates.contains(certificateHash) {
return true
}
}
// Check public key pinning
if !pinnedPublicKeys.isEmpty {
if let publicKey = extractPublicKey(from: certificate) {
let publicKeyHash = hashPublicKey(publicKey)
if pinnedPublicKeys.contains(publicKeyHash) {
return true
}
}
}
}
return false
}
private func hashCertificate(data: CFData) -> String {
let dataBytes = CFDataGetBytePtr(data)!
let dataLength = CFDataGetLength(data)
let hash = SHA256.hash(data: Data(bytes: dataBytes, count: dataLength))
return Data(hash).base64EncodedString()
}
private func extractPublicKey(from certificate: SecCertificate) -> SecKey? {
return SecCertificateCopyKey(certificate)
}
private func hashPublicKey(_ publicKey: SecKey) -> String {
guard let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil) else {
return ""
}
let hash = SHA256.hash(data: publicKeyData as Data)
return Data(hash).base64EncodedString()
}
}TrustKit Integration (iOS)
swift
import TrustKit
class TrustKitManager {
static func configure() {
let trustKitConfig: [String: Any] = [
kTSKSwizzleNetworkDelegates: false,
kTSKPinnedDomains: [
"api.example.com": [
kTSKEnforcePinning: true,
kTSKIncludeSubdomains: true,
kTSKPublicKeyHashes: [
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", // Current key
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=", // Backup key
],
kTSKReportUris: ["https://api.example.com/security/pin-failure"]
],
"*.example.com": [
kTSKEnforcePinning: true,
kTSKPublicKeyHashes: [
"CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=",
]
]
]
]
TrustKit.initSharedInstance(withConfiguration: trustKitConfig)
}
func createSecureURLSession() -> URLSession {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
return URLSession(
configuration: configuration,
delegate: TrustKit.sharedInstance().pinningValidator,
delegateQueue: nil
)
}
}Cross-Platform Certificate Pinning
React Native Implementation
typescript
// React Native certificate pinning with react-native-cert-pinner
import CertPinner from 'react-native-cert-pinner';
class NetworkSecurityManager {
private static instance: NetworkSecurityManager;
static getInstance(): NetworkSecurityManager {
if (!NetworkSecurityManager.instance) {
NetworkSecurityManager.instance = new NetworkSecurityManager();
}
return NetworkSecurityManager.instance;
}
async initializeCertificatePinning(): Promise<void> {
const pins = [
{
hostname: 'api.example.com',
pin: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
},
{
hostname: '*.example.com',
pin: 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB='
}
];
await CertPinner.pin(pins);
}
async makeSecureRequest(url: string, options: RequestInit = {}): Promise<Response> {
try {
// Use fetch with certificate pinning
const response = await fetch(url, {
...options,
// Certificate pinning is handled by native module
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response;
} catch (error) {
if (error.message.includes('Certificate pinning failure')) {
// Handle pinning failure
this.handlePinningFailure(url, error);
}
throw error;
}
}
private handlePinningFailure(url: string, error: Error): void {
// Log security incident
console.error('Certificate pinning failure:', { url, error: error.message });
// Report to security monitoring
this.reportSecurityIncident({
type: 'CERTIFICATE_PINNING_FAILURE',
url,
timestamp: new Date().toISOString(),
error: error.message
});
}
private async reportSecurityIncident(incident: SecurityIncident): Promise<void> {
// Report to security monitoring service
try {
await fetch('/api/security/incidents', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(incident)
});
} catch (error) {
console.error('Failed to report security incident:', error);
}
}
}
interface SecurityIncident {
type: string;
url: string;
timestamp: string;
error: string;
}Flutter Implementation
dart
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:dio_certificate_pinning/dio_certificate_pinning.dart';
import 'package:crypto/crypto.dart';
class CertificatePinningManager {
static final CertificatePinningManager _instance = CertificatePinningManager._internal();
late Dio _dio;
factory CertificatePinningManager() {
return _instance;
}
CertificatePinningManager._internal() {
_initializeDio();
}
void _initializeDio() {
_dio = Dio();
// Add certificate pinning interceptor
_dio.interceptors.add(
CertificatePinningInterceptor(
allowedSHAFingerprints: [
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', // api.example.com
'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=', // backup cert
],
),
);
// Custom certificate callback for additional validation
(_dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (client) {
client.badCertificateCallback = (cert, host, port) {
return _validateCertificate(cert, host);
};
return client;
};
}
bool _validateCertificate(X509Certificate cert, String host) {
// Additional certificate validation logic
if (!_isAllowedHost(host)) {
return false;
}
// Validate certificate against pinned certificates
String certFingerprint = _getCertificateFingerprint(cert);
return _pinnedFingerprints.contains(certFingerprint);
}
String _getCertificateFingerprint(X509Certificate cert) {
var digest = sha256.convert(cert.der);
return base64.encode(digest.bytes);
}
bool _isAllowedHost(String host) {
List<String> allowedHosts = [
'api.example.com',
'secure.example.com',
];
return allowedHosts.any((allowedHost) {
if (allowedHost.startsWith('*.')) {
String domain = allowedHost.substring(2);
return host.endsWith(domain);
}
return host == allowedHost;
});
}
static const List<String> _pinnedFingerprints = [
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=',
];
Future<Response> makeSecureRequest(
String url, {
String method = 'GET',
Map<String, dynamic>? data,
Map<String, dynamic>? headers,
}) async {
try {
Response response;
switch (method.toUpperCase()) {
case 'GET':
response = await _dio.get(url, options: Options(headers: headers));
break;
case 'POST':
response = await _dio.post(url, data: data, options: Options(headers: headers));
break;
case 'PUT':
response = await _dio.put(url, data: data, options: Options(headers: headers));
break;
case 'DELETE':
response = await _dio.delete(url, options: Options(headers: headers));
break;
default:
throw ArgumentError('Unsupported HTTP method: $method');
}
return response;
} on DioError catch (e) {
if (e.type == DioErrorType.other && e.message.contains('CERTIFICATE_VERIFY_FAILED')) {
_handleCertificatePinningFailure(url, e);
}
rethrow;
}
}
void _handleCertificatePinningFailure(String url, DioError error) {
// Log security incident
print('Certificate pinning failure for $url: ${error.message}');
// Report to security monitoring
_reportSecurityIncident({
'type': 'CERTIFICATE_PINNING_FAILURE',
'url': url,
'timestamp': DateTime.now().toIso8601String(),
'error': error.message,
});
}
Future<void> _reportSecurityIncident(Map<String, dynamic> incident) async {
try {
await _dio.post('/api/security/incidents', data: incident);
} catch (e) {
print('Failed to report security incident: $e');
}
}
}Dynamic Certificate Pinning
Certificate Update Mechanism
kotlin
class DynamicCertificatePinningManager {
private val secureStorage = SecureStorageManager()
private val apiService = CertificateApiService()
suspend fun updateCertificatePins(): Boolean {
return try {
val latestPins = apiService.getLatestCertificatePins()
// Validate new pins before applying
if (validateNewPins(latestPins)) {
secureStorage.storeCertificatePins(latestPins)
rebuildHttpClient()
true
} else {
false
}
} catch (e: Exception) {
Log.e("CertPinning", "Failed to update certificate pins", e)
false
}
}
private suspend fun validateNewPins(pins: List<CertificatePin>): Boolean {
// Test each pin with a simple request
return pins.all { pin ->
try {
val testClient = createTestClient(listOf(pin))
val response = testClient.newCall(
Request.Builder()
.url("https://${pin.hostname}/health")
.build()
).execute()
response.isSuccessful
} catch (e: Exception) {
false
}
}
}
private fun rebuildHttpClient() {
val pins = secureStorage.getCertificatePins()
val pinnedHosts = pins.map { "${it.hostname}:${it.pin}" }
val certificatePinner = CertificatePinner.Builder()
.apply {
pins.forEach { pin ->
add(pin.hostname, pin.pin)
}
}
.build()
// Rebuild and replace the HTTP client
NetworkManager.updateClient(
OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()
)
}
}Certificate Pinning Best Practices
Implementation Guidelines
- Pin Multiple Certificates: Include backup certificates
- Pin Intermediate CAs: Consider pinning intermediate certificates
- Regular Updates: Implement certificate rotation mechanism
- Graceful Degradation: Handle pinning failures appropriately
- Debug Configuration: Disable pinning in debug builds
Monitoring and Alerting
kotlin
class CertificatePinningMonitor {
private val analyticsService = AnalyticsService()
fun reportPinningFailure(
hostname: String,
expectedPins: List<String>,
actualCertificate: String,
userAgent: String
) {
val event = SecurityEvent(
type = "CERTIFICATE_PINNING_FAILURE",
timestamp = System.currentTimeMillis(),
hostname = hostname,
expectedPins = expectedPins,
actualCertificate = actualCertificate,
userAgent = userAgent,
appVersion = BuildConfig.VERSION_NAME
)
// Send to security monitoring
analyticsService.trackSecurityEvent(event)
// Log locally for debugging
Log.w("CertPinning", "Pinning failure: $event")
}
fun reportPinningSuccess(hostname: String, pinnedCertificate: String) {
val event = SecurityEvent(
type = "CERTIFICATE_PINNING_SUCCESS",
timestamp = System.currentTimeMillis(),
hostname = hostname,
pinnedCertificate = pinnedCertificate
)
analyticsService.trackSecurityEvent(event)
}
}Testing Certificate Pinning
swift
#if DEBUG
class CertificatePinningTester {
func testCertificatePinning() {
// Test valid certificate
testPinningForHost("api.example.com", expectSuccess: true)
// Test invalid certificate (should fail)
testPinningForHost("badssl.com", expectSuccess: false)
// Test certificate rotation
testCertificateRotation()
}
private func testPinningForHost(_ hostname: String, expectSuccess: Bool) {
let expectation = XCTestExpectation(description: "Certificate pinning test for \(hostname)")
let url = URL(string: "https://\(hostname)")!
let task = URLSession.shared.dataTask(with: url) { data, response, error in
if expectSuccess {
XCTAssertNil(error, "Expected successful connection")
} else {
XCTAssertNotNil(error, "Expected connection failure")
}
expectation.fulfill()
}
task.resume()
wait(for: [expectation], timeout: 10.0)
}
private func testCertificateRotation() {
// Test that backup certificates work
// Implementation depends on your backup certificate strategy
}
}
#endifSecurity Considerations
Certificate Rotation Strategy
- Plan Ahead: Always include backup certificates
- Gradual Rollout: Update pins gradually across user base
- Monitoring: Monitor pinning failures during rotation
- Rollback Plan: Have a rollback mechanism for failed rotations
Emergency Procedures
- Pin Bypass: Implement emergency pin bypass mechanism
- Kill Switch: Remote configuration to disable pinning
- Incident Response: Clear procedures for pinning-related incidents
Certificate pinning is a powerful security measure that significantly reduces the risk of MITM attacks. However, it requires careful implementation, monitoring, and maintenance to be effective without causing service disruptions.
