Certificate Pinning Testing in Mobile Applications: SSL/TLS Validation, MITM Protection, and Pin Rotation is a critical discipline in modern software quality assurance. According to IBM’s Cost of a Data Breach Report 2024, the global average cost of a data breach reached $4.88 million (IBM Cost of a Data Breach 2024). According to OWASP, injection vulnerabilities and broken authentication remain in the top 10 web application security risks (OWASP Top 10). This guide covers practical approaches that QA teams can apply immediately: from core concepts and tooling to real-world implementation patterns. Whether you are building skills in this area or improving an existing process, you will find actionable techniques backed by industry experience. The goal is not just theoretical understanding but a working framework you can adapt to your team’s context, technology stack, and quality objectives.

TL;DR

  • Security testing belongs in the sprint, not at the end of the release cycle
  • Use SAST for early detection and DAST for runtime vulnerability discovery
  • The OWASP Top 10 covers the most critical risks every web application team must address

Best for: Teams building customer-facing or data-handling applications Skip if: Purely internal tools with no sensitive data or external access

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.

“Security testing belongs in the sprint, not at the end of the release cycle. A vulnerability found by your team during development costs 10x less to fix than one found by a penetration tester before launch.” — Yuri Kan, Senior QA Lead

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.

Official Resources

FAQ

What is the difference between SAST and DAST? SAST (Static Analysis) scans source code without executing it, finding issues early. DAST (Dynamic Analysis) tests running applications, finding runtime vulnerabilities like injection and authentication flaws.

When should security testing be performed? Security testing should begin in the design phase with threat modeling, continue through development with SAST, and conclude before each release with DAST and penetration testing.

What are the most common web application vulnerabilities? The OWASP Top 10 covers the most critical risks: injection, broken authentication, sensitive data exposure, security misconfiguration, XSS, and insecure deserialization.

How do you test for SQL injection? Use automated scanners like sqlmap for initial discovery, then manually verify by testing boundary conditions, special characters, and time-based blind injection patterns in all input fields.

See Also