Sparround

Network security: TLS, certificate pinning

HTTPS/TLS encrypts traffic, but whom you trust is defined by the system CA store. The problem: a malicious CA can be installed on the user's device (or a CA itself gets compromised) → a MITM (man-in-the-middle) attack.

Certificate pinning — the app trusts only its own server's certificate (usually the SHA-256 hash of the public key); even if the chain looks "trusted", a pin mismatch kills the connection.

Two implementation routes:

  • OkHttp CertificatePinner — pins in code
  • Network Security Config — declarative in res/xml/network_security_config.xml: pin-sets + expiration, cleartext bans, debug-overrides

The critical rule — a backup pin: pin only the current certificate and a rotation locks out every user, rescued only by an app update. Always pin a standby key too.

Also: cleartextTrafficPermitted="false" (default since API 28) — HTTP fully banned; debug CAs allowed only inside debug-overrides.

kotlin
// 1) OkHttp CertificatePinner — backup pin MÜTLƏQ
val pinner = CertificatePinner.Builder()
    .add("api.bank.example", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")  // cari
    .add("api.bank.example", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")  // ehtiyat
    .build()

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

// 2) network_security_config.xml — deklarativ
// <network-security-config>
//   <domain-config cleartextTrafficPermitted="false">
//     <domain includeSubdomains="true">api.bank.example</domain>
//     <pin-set expiration="2027-01-01">
//       <pin digest="SHA-256">AAAA...=</pin>
//       <pin digest="SHA-256">BBBB...=</pin>
//     </pin-set>
//   </domain-config>
// </network-security-config>

Pinning both ways

What the pin binds to — a nuance you must know: usually the public key (SPKI hash), not the leaf certificate itself — renewing the certificate while keeping the key leaves the pin valid. Pinning an intermediate CA is the softer policy, pinning the leaf the strictest. Pin expiration is the security-availability trade-off: past the date, pinning disables so a forgotten rotation cannot brick the app forever.

🛠 Practice task

Run a MITM in your own lab:

  • Install Charles or mitmproxy, add its certificate to the device and read the app's traffic without pinning.
  • Then add a CertificatePinner (or a network security config pin-set) — watch the request die with SSLPeerUnverifiedException.
  • Set up the debug/release split: debug-overrides in debug builds only.

Done when: you added a backup pin and can explain the rotation risk it covers.

📚 Sources and documentation