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
- Always use backup pins - Include 2-3 pins for rotation
- Pin to public keys, not certificates - More flexible for cert rotation
- Monitor pin expiration - Set up alerts before certs expire
- Test pinning in CI/CD - Detect misconfiguration early
- Implement remote pin updates - For emergency rotation
- Log pinning failures - Monitor for MITM attempts
- 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
- Mobile Security Testing
- Requestly: HTTP Interception and Request Modification - HTTP interception with Requestly: modify headers, redirect URLs, mock…
- Comprehensive guide to iOS and Android security testing
- API Security Testing - Securing API communications and preventing common vulnerabilities
- Security Test Documentation - Best practices for documenting security findings
- Continuous Testing DevOps - Integrating mobile security tests into CI/CD pipelines
- Containerization for Testing - Creating isolated environments for mobile security testing
