Certificate Pinning Testing Guide: Mobile Apps, Bypass Techniques & OkHttp Tests
Certificate pinning prevents man-in-the-middle attacks by restricting which certificates an app trusts. Without pinning, installing a custom CA certificate on a device is enough to intercept HTTPS traffic — the system trust store accepts it and the app never knows. With pinning, the app checks the certificate against a hardcoded list and rejects anything else, even certificates signed by a trusted CA.
Testing certificate pinning verifies two things: that pinning is actually enforced (intercepted traffic is blocked), and that your pinning implementation doesn't break legitimate connections.
How Certificate Pinning Works
Three pinning approaches:
Certificate pinning — the exact DER-encoded certificate is pinned. Strongest guarantee, but every renewal requires an app update.
Public key pinning — the certificate's public key is pinned. The key can persist across certificate renewals if you keep the same key pair. This is what OkHttp's CertificatePinner does.
CA pinning — only certificates issued by a specific CA are trusted. Weakest form, but easiest to maintain (no update needed when cert renews).
For mobile apps, public key pinning (SPKI pinning) is the practical standard.
Getting the Pin Value
The pin is the Base64-encoded SHA-256 hash of the Subject Public Key Info (SPKI):
# From a live certificate
openssl s_client -connect api.example.com:443 \
-servername api.example.com \
</dev/null 2>/dev/null <span class="hljs-pipe">| \
openssl x509 -pubkey -noout <span class="hljs-pipe">| \
openssl pkey -pubin -outform der <span class="hljs-pipe">| \
openssl dgst -sha256 -binary <span class="hljs-pipe">| \
<span class="hljs-built_in">base64
<span class="hljs-comment"># From a certificate file
openssl x509 -<span class="hljs-keyword">in cert.pem -pubkey -noout <span class="hljs-pipe">| \
openssl pkey -pubin -outform der <span class="hljs-pipe">| \
openssl dgst -sha256 -binary <span class="hljs-pipe">| \
<span class="hljs-built_in">base64
<span class="hljs-comment"># From a P12/PKCS12 file
openssl pkcs12 -<span class="hljs-keyword">in cert.p12 -nokeys -clcerts -nodes <span class="hljs-pipe">| \
openssl x509 -pubkey -noout <span class="hljs-pipe">| \
openssl pkey -pubin -outform der <span class="hljs-pipe">| \
openssl dgst -sha256 -binary <span class="hljs-pipe">| \
<span class="hljs-built_in">base64Pin multiple certificates (current + backup):
# Pin current cert
CURRENT_PIN=$(openssl s_client -connect api.example.com:443 </dev/null 2>/dev/null <span class="hljs-pipe">| \
openssl x509 -pubkey -noout <span class="hljs-pipe">| openssl pkey -pubin -outform der <span class="hljs-pipe">| \
openssl dgst -sha256 -binary <span class="hljs-pipe">| <span class="hljs-built_in">base64)
<span class="hljs-built_in">echo <span class="hljs-string">"Current pin: sha256/$CURRENT_PIN"OkHttp CertificatePinner Tests
OkHttp's CertificatePinner is the standard pinning implementation for Android apps.
Basic Implementation
// In your networking setup
val certificatePinner = CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") // backup
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()Unit Tests for CertificatePinner
import okhttp3.CertificatePinner
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Test
import java.net.SocketException
import javax.net.ssl.SSLPeerUnverifiedException
import kotlin.test.assertFailsWith
import kotlin.test.assertEquals
class CertificatePinningTest {
private lateinit var server: MockWebServer
private val testPins = listOf(
"sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", // current cert
"sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=" // backup cert
)
@Before
fun setUp() {
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun `certificatePinner is configured with correct pins`() {
val pinner = buildProductionCertificatePinner()
// Verify the pinner has pins for the expected host
val pins = pinner.findMatchingPins("api.example.com")
assert(pins.isNotEmpty()) { "No pins configured for api.example.com" }
assert(pins.size >= 2) { "Expected at least 2 pins (current + backup)" }
}
@Test
fun `request succeeds with matching certificate`() {
// Setup mock server with self-signed cert
val heldCertificate = HeldCertificate.Builder()
.addSubjectAlternativeName("localhost")
.build()
val serverCertificates = HandshakeCertificates.Builder()
.heldCertificate(heldCertificate)
.build()
server.useHttps(serverCertificates.sslSocketFactory(), false)
server.enqueue(MockResponse().setBody("OK"))
// Get the actual pin for this cert
val pin = CertificatePinner.pin(heldCertificate.certificate)
val client = OkHttpClient.Builder()
.certificatePinner(
CertificatePinner.Builder()
.add("localhost", pin)
.build()
)
.sslSocketFactory(
serverCertificates.sslSocketFactory(),
serverCertificates.trustManager
)
.build()
val response = client.newCall(
Request.Builder().url(server.url("/")).build()
).execute()
assertEquals(200, response.code)
assertEquals("OK", response.body?.string())
}
@Test
fun `request fails with non-pinned certificate`() {
// Server uses one cert
val serverCert = HeldCertificate.Builder()
.addSubjectAlternativeName("localhost")
.build()
val serverHandshake = HandshakeCertificates.Builder()
.heldCertificate(serverCert)
.build()
server.useHttps(serverHandshake.sslSocketFactory(), false)
server.enqueue(MockResponse().setBody("Should not reach here"))
// Client pins a different cert
val differentCert = HeldCertificate.Builder()
.addSubjectAlternativeName("localhost")
.build()
val wrongPin = CertificatePinner.pin(differentCert.certificate)
val client = OkHttpClient.Builder()
.certificatePinner(
CertificatePinner.Builder()
.add("localhost", wrongPin)
.build()
)
.sslSocketFactory(
serverHandshake.sslSocketFactory(),
serverHandshake.trustManager
)
.build()
// Must throw SSLPeerUnverifiedException
assertFailsWith<SSLPeerUnverifiedException> {
client.newCall(
Request.Builder().url(server.url("/")).build()
).execute()
}
}
@Test
fun `backup pin is accepted when primary cert rotated`() {
val primaryCert = HeldCertificate.Builder()
.addSubjectAlternativeName("localhost")
.build()
val backupCert = HeldCertificate.Builder()
.addSubjectAlternativeName("localhost")
.build()
val primaryPin = CertificatePinner.pin(primaryCert.certificate)
val backupPin = CertificatePinner.pin(backupCert.certificate)
// Client configured with both pins
val pinner = CertificatePinner.Builder()
.add("localhost", primaryPin)
.add("localhost", backupPin)
.build()
// Server uses backup cert (simulates cert rotation)
val serverHandshake = HandshakeCertificates.Builder()
.heldCertificate(backupCert)
.build()
server.useHttps(serverHandshake.sslSocketFactory(), false)
server.enqueue(MockResponse().setBody("OK"))
val client = OkHttpClient.Builder()
.certificatePinner(pinner)
.sslSocketFactory(
serverHandshake.sslSocketFactory(),
serverHandshake.trustManager
)
.build()
// Should succeed — backup pin matches
val response = client.newCall(
Request.Builder().url(server.url("/")).build()
).execute()
assertEquals(200, response.code)
}
}Integration Test Against Real Endpoint
@Test
fun `production api endpoint accepts pinned certificate`() {
// This test verifies the actual production certificate matches the pin
// Run this before releasing a new app version
val client = buildProductionHttpClient() // Uses production CertificatePinner config
val request = Request.Builder()
.url("https://api.example.com/health")
.build()
val response = client.newCall(request).execute()
assertEquals(200, response.code,
"API health check failed — check if certificate was rotated without updating pins")
}Run this test in CI against staging to catch certificate rotation before it breaks production.
iOS Testing (URLSession / TrustKit)
NSURLSessionDelegate Pinning Test
import XCTest
import Security
class CertificatePinningTests: XCTestCase {
let apiHost = "api.example.com"
let expectedPins = [
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", // SHA256 of SPKI
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="
]
func testLiveEndpointMatchesPinnedCertificate() {
let expectation = self.expectation(description: "Pin verification")
let session = URLSession(
configuration: .default,
delegate: PinningDelegate(expectedPins: expectedPins),
delegateQueue: nil
)
var requestSucceeded = false
let task = session.dataTask(with: URL(string: "https://\(apiHost)/health")!) { data, response, error in
if let error = error {
XCTFail("Request failed — certificate pinning rejected: \(error.localizedDescription)")
} else if let http = response as? HTTPURLResponse {
requestSucceeded = http.statusCode == 200
}
expectation.fulfill()
}
task.resume()
wait(for: [expectation], timeout: 10.0)
XCTAssertTrue(requestSucceeded)
}
}
class PinningDelegate: NSObject, URLSessionDelegate {
let expectedPins: [String]
init(expectedPins: [String]) {
self.expectedPins = expectedPins
}
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Get leaf certificate
let certCount = SecTrustGetCertificateCount(serverTrust)
guard let leafCert = SecTrustGetCertificateAtIndex(serverTrust, 0) else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Get SPKI hash
let pin = publicKeyPin(for: leafCert)
if expectedPins.contains(pin) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
private func publicKeyPin(for certificate: SecCertificate) -> String {
// Extract public key and compute SHA256
let key = SecCertificateCopyKey(certificate)!
let keyData = SecKeyCopyExternalRepresentation(key, nil)! as Data
var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
keyData.withUnsafeBytes { CC_SHA256($0.baseAddress, CC_LONG(keyData.count), &hash) }
return Data(hash).base64EncodedString()
}
}Bypass Techniques for Testing
Security testing requires bypassing pinning to intercept traffic. These techniques are for testing your own app against your own servers only.
Frida-Based Bypass (Android)
Frida hooks the pinning implementation at runtime:
// frida-script.js — hooks OkHttp CertificatePinner
Java.perform(function() {
// Hook OkHttp CertificatePinner
var CertificatePinner = Java.use('okhttp3.CertificatePinner');
CertificatePinner.check.overload('java.lang.String', 'java.util.List').implementation = function(hostname, peerCertificates) {
console.log('[*] CertificatePinner.check() bypassed for: ' + hostname);
// Do nothing — skip the pin check
};
CertificatePinner.check.overload('java.lang.String', 'kotlin.jvm.functions.Function0').implementation = function(hostname, callback) {
console.log('[*] CertificatePinner.check() bypassed for: ' + hostname);
};
console.log('[+] Certificate pinning bypass loaded');
});Run:
frida -U -f com.example.app -l frida-script.js --no-pauseAfter bypass, route traffic through Burp Suite or mitmproxy.
SSL Kill Switch (iOS)
Install SSL Kill Switch 2 via Cydia on a jailbroken device. It patches SecTrustEvaluate to always return success, bypassing all certificate validation.
mitmproxy with Custom CA
The simplest proxy-based bypass on rooted Android:
# Start mitmproxy
mitmproxy -p 8080
<span class="hljs-comment"># On Android (rooted), install mitmproxy CA cert to system store
adb push mitmproxy-ca-cert.pem /sdcard/
adb shell <span class="hljs-string">"su -c 'cp /sdcard/mitmproxy-ca-cert.pem /system/etc/security/cacerts/mitmproxy.pem'"
adb shell <span class="hljs-string">"su -c 'chmod 644 /system/etc/security/cacerts/mitmproxy.pem'"This works when pinning is done via the system trust store (no custom implementation). Custom pinning implementations (OkHttp, TrustKit) require Frida.
Verifying the Bypass Worked
After applying bypass:
# Confirm traffic is visible in proxy
<span class="hljs-comment"># If you see API calls in mitmproxy/Burp, pinning is bypassed
<span class="hljs-comment"># Confirm pinning is re-enforced after bypass removed
<span class="hljs-comment"># Remove frida script, restart app
<span class="hljs-comment"># Traffic should become opaque againNetwork Security Config Testing (Android)
Android's Network Security Config provides declarative pinning:
<!-- 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 expiration="2027-01-01">
<pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
<pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin>
</pin-set>
</domain-config>
</network-security-config>Test it's applied:
# Verify AndroidManifest.xml references the config
grep -r <span class="hljs-string">"networkSecurityConfig" app/src/main/AndroidManifest.xml
<span class="hljs-comment"># Verify pin values are correct (generate fresh)
openssl s_client -connect api.example.com:443 </dev/null 2>/dev/null <span class="hljs-pipe">| \
openssl x509 -pubkey -noout <span class="hljs-pipe">| \
openssl pkey -pubin -outform der <span class="hljs-pipe">| \
openssl dgst -sha256 -binary <span class="hljs-pipe">| \
<span class="hljs-built_in">base64CI Integration: Pin Freshness Check
Automatically verify that pinned values match the live server before every release build:
#!/bin/bash
<span class="hljs-comment"># check-pins.sh — run before building release
API_HOST=<span class="hljs-string">"api.example.com"
<span class="hljs-comment"># Get current live pin
LIVE_PIN=$(openssl s_client -connect <span class="hljs-string">"$API_HOST:443" -servername <span class="hljs-string">"$API_HOST" \
</dev/null 2>/dev/null <span class="hljs-pipe">| \
openssl x509 -pubkey -noout <span class="hljs-pipe">| \
openssl pkey -pubin -outform der <span class="hljs-pipe">| \
openssl dgst -sha256 -binary <span class="hljs-pipe">| \
<span class="hljs-built_in">base64)
<span class="hljs-built_in">echo <span class="hljs-string">"Live pin: sha256/$LIVE_PIN"
<span class="hljs-comment"># Extract pins from Android config
CONFIGURED_PINS=$(grep -A2 <span class="hljs-string">'<pin digest="SHA-256">' \
app/src/main/res/xml/network_security_config.xml <span class="hljs-pipe">| \
grep -o <span class="hljs-string">'[A-Za-z0-9+/=]\{44\}')
<span class="hljs-built_in">echo <span class="hljs-string">"Configured pins:"
<span class="hljs-built_in">echo <span class="hljs-string">"$CONFIGURED_PINS"
<span class="hljs-comment"># Check if live pin is in configured set
<span class="hljs-keyword">if <span class="hljs-built_in">echo <span class="hljs-string">"$CONFIGURED_PINS" <span class="hljs-pipe">| grep -q <span class="hljs-string">"$LIVE_PIN"; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"PASS: Live certificate pin matches a configured pin"
<span class="hljs-built_in">exit 0
<span class="hljs-keyword">else
<span class="hljs-built_in">echo <span class="hljs-string">"FAIL: Live certificate pin NOT in configured pins"
<span class="hljs-built_in">echo <span class="hljs-string">"Update network_security_config.xml before releasing"
<span class="hljs-built_in">exit 1
<span class="hljs-keyword">fiAdd to your CI workflow:
- name: Verify certificate pins
run: bash scripts/check-pins.sh
# Fails if the pinned value doesn't match the current certWhen Pinning Breaks Things
Certificate renewal without pin update — the most common failure. Generate new pins before rotating the certificate. Ship the app update with backup pins first, rotate the cert, then clean up.
CDN or load balancer certificate — the certificate the app sees may be from a CDN, not your origin. Pin the CDN certificate, or use the CDN's public key if it's consistent.
Debug/staging environments — disable pinning in debug builds or implement per-environment pin configuration. Never put production pins in debug builds.
Corporate proxy — some corporate networks perform TLS inspection. Apps with strict pinning fail in these environments. This is usually acceptable; document it as a known limitation.