Certificate pinning strengthens SSL/TLS security by validating that the server’s certificate matches a known, trusted certificate. This prevents man-in-the-middle (MITM) attacks even when the device’s trust store is compromised.

For a comprehensive overview of mobile security fundamentals, see our Mobile Security Testing guide.

Why Certificate Pinning Matters

Standard SSL/TLS validation trusts any certificate signed by a Certificate Authority (CA) in the device’s trust store. This creates vulnerabilities:

  • Compromised CAs
  • Malicious certificates installed by malware
  • Corporate proxies performing SSL interception

Certificate pinning mitigates these risks by only trusting specific certificates or public keys.

Android Certificate Pinning

OkHttp CertificatePinner

import okhttp3.CertificatePinner

val certificatePinner = CertificatePinner.Builder()
    .add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
    .add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // Backup pin
    .build()

val client = OkHttpClient.Builder()
    .certificatePinner(certificatePinner)
    .build()

Generating Certificate Pins

# Extract certificate from server
openssl s_client -connect api.example.com:443 -servername api.example.com < /dev/null \
    | openssl x509 -outform DER > cert.der

# Generate SHA-256 hash
openssl x509 -in cert.der -inform DER -pubkey -noout \
    | openssl pkey -pubin -outform DER \
    | openssl dgst -sha256 -binary \
    | base64

# Output: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

Testing Certificate Pinning

class CertificatePinningTest {

    @Test
    fun testValidCertificate() = runTest {
        val client = createClientWithPinning()

        val response = client.newCall(
            Request.Builder()
                .url("https://api.example.com/users")
                .build()
        ).execute()

        assertTrue(response.isSuccessful)
    }

    @Test
    fun testInvalidCertificate() = runTest {
        val client = createClientWithPinning()

        // Use invalid hostname
        assertThrows<SSLPeerUnverifiedException> {
            client.newCall(
                Request.Builder()
                    .url("https://evil.example.com/users")
                    .build()
            ).execute()
        }
    }

    @Test
    fun testMITMAttempt() = runTest {
        // Set up proxy with self-signed certificate
        val proxyClient = OkHttpClient.Builder()
            .proxy(Proxy(Proxy.Type.HTTP, InetSocketAddress("localhost", 8888)))
            .certificatePinner(certificatePinner)
            .build()

        // Should fail due to certificate mismatch
        assertThrows<SSLHandshakeException> {
            proxyClient.newCall(
                Request.Builder()
                    .url("https://api.example.com/users")
                    .build()
            ).execute()
        }
    }
}

iOS Certificate Pinning

URLSession Delegate

import Foundation
import CommonCrypto

class CertificatePinningDelegate: NSObject, URLSessionDelegate {
    private let pinnedCertificates: Set<Data>

    init(pinnedCertificates: Set<Data>) {
        self.pinnedCertificates = pinnedCertificates
    }

    func urlSession(
        _ session: URLSession,
        didReceive challenge: URLAuthenticationChallenge,
        completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
    ) {
        guard let serverTrust = challenge.protectionSpace.serverTrust else {
            completionHandler(.cancelAuthenticationChallenge, nil)
            return
        }

        let certificates = (0..<SecTrustGetCertificateCount(serverTrust)).compactMap { index in
            SecTrustGetCertificateAtIndex(serverTrust, index)
        }

        for certificate in certificates {
            let certificateData = SecCertificateCopyData(certificate) as Data

            if pinnedCertificates.contains(certificateData) {
                let credential = URLCredential(trust: serverTrust)
                completionHandler(.useCredential, credential)
                return
            }
        }

        completionHandler(.cancelAuthenticationChallenge, nil)
    }
}

// Usage
let pinnedCerts = Set([loadCertificate(named: "api_example_com")])
let delegate = CertificatePinningDelegate(pinnedCertificates: pinnedCerts)

let session = URLSession(
    configuration: .default,
    delegate: delegate,
    delegateQueue: nil
)

Testing iOS Pinning

class CertificatePinningTests: XCTestCase {
    func testValidCertificate() async throws {
        let pinnedCerts = Set([loadCertificate(named: "valid_cert")])
        let delegate = CertificatePinningDelegate(pinnedCertificates: pinnedCerts)

        let session = URLSession(
            configuration: .default,
            delegate: delegate,
            delegateQueue: nil
        )

        let url = URL(string: "https://api.example.com/users")!
        let (data, response) = try await session.data(from: url)

        XCTAssertNotNil(data)
        XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
    }

    func testInvalidCertificate() async {
        let pinnedCerts = Set([loadCertificate(named: "valid_cert")])
        let delegate = CertificatePinningDelegate(pinnedCertificates: pinnedCerts)

        let session = URLSession(
            configuration: .default,
            delegate: delegate,
            delegateQueue: nil
        )

        let url = URL(string: "https://evil.example.com/users")!

        do {
            _ = try await session.data(from: url)
            XCTFail("Should have thrown error")
        } catch {
            // Expected failure
            XCTAssertTrue(error is URLError)
        }
    }
}

Public Key Pinning

More flexible than certificate pinning, allows certificate rotation without app update.

Android Public Key Pinning

class PublicKeyPinning : CertificatePinner {
    private val pinnedPublicKeys = setOf(
        "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
        "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
    )

    override fun check(hostname: String, peerCertificates: List<Certificate>) {
        val publicKeyHashes = peerCertificates.map { cert ->
            val publicKey = cert.publicKey.encoded
            sha256(publicKey).base64()
        }

        if (publicKeyHashes.none { it in pinnedPublicKeys }) {
            throw SSLPeerUnverifiedException("Certificate pinning failure!")
        }
    }

    private fun sha256(data: ByteArray): ByteArray {
        return MessageDigest.getInstance("SHA-256").digest(data)
    }
}

Pin Rotation Strategy

Backup Pins

Always include backup pins to allow graceful rotation:

val certificatePinner = CertificatePinner.Builder()
    .add("api.example.com",
        "sha256/PRIMARY_PIN_HASH")
    .add("api.example.com",
        "sha256/BACKUP_PIN_HASH_1")
    .add("api.example.com",
        "sha256/BACKUP_PIN_HASH_2")
    .build()

Remote Pin Configuration

Fetch pins from a remote configuration service:

class DynamicPinningManager(
    private val configService: ConfigService,
    private val secureStorage: SecureStorage
) {
    suspend fun updatePins() {
        val newPins = configService.getCertificatePins()

        // Validate new pins before applying
        if (validatePins(newPins)) {
            secureStorage.savePins(newPins)
        }
    }

    fun createClient(): OkHttpClient {
        val pins = secureStorage.getPins()
        val pinnerBuilder = CertificatePinner.Builder()

        pins.forEach { (hostname, pinHashes) ->
            pinHashes.forEach { hash ->
                pinnerBuilder.add(hostname, "sha256/$hash")
            }
        }

        return OkHttpClient.Builder()
            .certificatePinner(pinnerBuilder.build())
            .build()
    }
}

@Test
fun testPinRotation() = runTest {
    val manager = DynamicPinningManager(mockConfigService, storage)

    // Initial pins
    val initialPins = mapOf("api.example.com" to setOf("OLD_PIN"))
    storage.savePins(initialPins)

    // Rotate pins
    mockConfigService.respondWith(mapOf(
        "api.example.com" to setOf("OLD_PIN", "NEW_PIN")
    ))

    manager.updatePins()

    // Should work with both old and new pin
    val client = manager.createClient()
    assertTrue(client.certificatePinner.pins.size == 2)
}

Testing MITM Detection

MITM detection is critical for mobile security. Learn more advanced security testing techniques in our Penetration Testing Basics guide.

Using Charles Proxy or mitmproxy

@Test
fun testMITMProxyDetection() = runTest {
    // Configure app to use proxy
    System.setProperty("http.proxyHost", "localhost")
    System.setProperty("http.proxyPort", "8888")

    val client = createClientWithPinning()

    // Should fail when proxy uses self-signed cert
    assertThrows<SSLPeerUnverifiedException> {
        client.newCall(
            Request.Builder()
                .url("https://api.example.com/users")
                .build()
        ).execute()
    }
}

Bypass Detection

Detecting Certificate Pinning Bypass Tools

class SecurityChecker {
    fun detectRootAndBypassTools(): SecurityStatus {
        val issues = mutableListOf<String>()

        // Check for root
        if (isDeviceRooted()) {
            issues.add("Device is rooted")
        }

        // Check for Frida
        if (isFridaRunning()) {
            issues.add("Frida detected")
        }

        // Check for Xposed
        if (isXposedInstalled()) {
            issues.add("Xposed framework detected")
        }

        // Check for emulator
        if (isEmulator()) {
            issues.add("Running on emulator")
        }

        return if (issues.isEmpty()) {
            SecurityStatus.Secure
        } else {
            SecurityStatus.Compromised(issues)
        }
    }

    private fun isDeviceRooted(): Boolean {
        val paths = arrayOf(
            "/system/app/Superuser.apk",
            "/sbin/su",
            "/system/bin/su",
            "/system/xbin/su"
        )

        return paths.any { File(it).exists() }
    }

    private fun isFridaRunning(): Boolean {
        try {
            val process = Runtime.getRuntime().exec("ps")
            val reader = BufferedReader(InputStreamReader(process.inputStream))
            return reader.lines().anyMatch { it.contains("frida") }
        } catch (e: Exception) {
            return false
        }
    }
}

@Test
fun testSecurityChecks() {
    val checker = SecurityChecker()
    val status = checker.detectRootAndBypassTools()

    when (status) {
        SecurityStatus.Secure -> {
            // Proceed normally
        }
        is SecurityStatus.Compromised -> {
            // Log security issues
            log.warn("Security issues detected: ${status.issues}")
        }
    }
}

Best Practices

  1. Always use backup pins - Include 2-3 pins for rotation
  2. Pin to public keys, not certificates - More flexible for cert rotation
  3. Monitor pin expiration - Set up alerts before certs expire
  4. Test pinning in CI/CD - Detect misconfiguration early
  5. Implement remote pin updates - For emergency rotation
  6. Log pinning failures - Monitor for MITM attempts
  7. Combine with other security measures - Root detection, SSL validation

Certificate pinning works best when combined with OAuth 2.0 and JWT testing for comprehensive authentication security. For QA engineers looking to expand security knowledge, check Security Testing for QA.

Conclusion

Certificate pinning testing ensures:

  • MITM attack prevention
  • Proper SSL/TLS validation
  • Graceful pin rotation
  • Detection of bypass attempts

Properly implemented and tested certificate pinning significantly enhances mobile app security, protecting sensitive data from interception attacks.