Certificate pinning done right on Android means pinning the hash of the server's public key (its SubjectPublicKeyInfo, or SPKI), never the leaf certificate, always shipping at least one backup pin, and having a rotation plan ready before you need it. You get two ways to do it — a declarative Network Security Config XML file that the framework enforces for all platform traffic, or the programmatic CertificatePinner in OkHttp — and the hard part is never the API. It is the operational discipline that keeps a routine certificate renewal from bricking every installed copy of your app overnight.
I have shipped pinning in a production banking app, including the NFC/eID flows I wrote about in reading an eID chip on Android, and the lesson that stuck is that pinning is a loaded gun pointed at your own availability. This post is about holding it the right way round.
Key takeaways
- Pin the public key (SPKI), not the certificate. A leaf certificate changes on every renewal; a key pair does not have to. Pinning the SPKI lets the server renew its cert without breaking the app.
- Two mechanisms: Network Security Config (declarative XML, covers all framework HTTPS traffic) and OkHttp's
CertificatePinner(programmatic, per-OkHttpClient). Pick one; do not half-configure both. - Always ship a backup pin. Without a second pin, the day you must switch keys or CAs you can only restore connectivity with an app update — and users who do not update are locked out.
- Plan rotation before you ship. Pin two keys from day one: the one in use and an offline backup. Rotate to the backup, then mint the next backup. A pin set with no exit plan is an outage with a delay timer.
- Never enforce pins against debug proxies. Keep pinning in release and use a debug-overrides trust anchor so Proxyman or Charles still work locally.
Why pin at all — and why Google is lukewarm on it
Normal Android TLS already validates the server certificate against the system trust store. Pinning adds a second, stricter check: the chain must also contain a specific key you chose in advance. That defeats an adversary who holds a validly-issued certificate from a CA your device trusts — a compromised or coerced CA, or a device with an attacker-installed root. For a banking or payments app, that threat is real enough to justify the cost.
The cost is availability. Google's own network security config guidance is deliberately cautious about pinning precisely because a mismatch is a hard connection failure with no graceful degradation. Pin wrong, let a pin lapse, or forget to add a backup, and the app simply stops talking to your backend. Pinning is not a default-on best practice; it is a deliberate trade of a little availability risk for a large reduction in MITM risk. Treat it like the mobile-hardening signals I catalogued in SecureCheck: every defensive control has a failure mode, and you own it.
Approach one: Network Security Config (declarative)
The framework-native way is an XML file referenced from the manifest. It covers every connection the Android platform manages — HttpsURLConnection, OkHttp when it defers to the platform trust manager, and WebView — without touching your networking code. Pins live in a <pin-set> on a <domain-config>:
<!-- res/xml/network_security_config.xml -->
<network-security-config>
<domain-config>
<domain includeSubdomains="true">api.example.com</domain>
<pin-set expiration="2027-10-01">
<!-- Base64 SHA-256 of the SPKI. Primary key in use today. -->
<pin digest="SHA-256">k3hBDZ8m0cJ8yq1Yd0pPtQx2cHjWn7p9vF4sL1aXuGk=</pin>
<!-- Backup key, held offline, not yet serving traffic. -->
<pin digest="SHA-256">YwFp2nL8qR6tZ3vK1mD9xS0cJ7hG4bN5aU8eW2oP1rM=</pin>
</pin-set>
</domain-config>
</network-security-config>
Wire it up once in the manifest:
<application android:networkSecurityConfig="@xml/network_security_config">
Two details matter here. digest="SHA-256" is the only digest the config supports, and the value is the base64 of a hash over the SPKI, not over the whole certificate. The expiration attribute is a safety valve: after that date the framework stops enforcing pinning and falls back to normal CA validation. That prevents a stale app from bricking itself, but it is a genuine security trade-off — past the date, an attacker with a rogue CA cert is back in business, so set it to a date you actively monitor and move forward on each release.
Approach two: OkHttp CertificatePinner (programmatic)
If your traffic goes through OkHttp and you want pinning in code — easier to test, easier to feature-flag — use CertificatePinner. Its pins are the same base64 SHA-256 SPKI hashes, prefixed with sha256/:
val certificatePinner = CertificatePinner.Builder()
.add(
"api.example.com",
"sha256/k3hBDZ8m0cJ8yq1Yd0pPtQx2cHjWn7p9vF4sL1aXuGk=", // primary
"sha256/YwFp2nL8qR6tZ3vK1mD9xS0cJ7hG4bN5aU8eW2oP1rM=", // backup
)
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()
Pinning is per hostname: api.example.com and www.api.example.com are distinct entries. This naturally belongs in a dedicated :core:network module so one place owns the pinned client and nothing else can quietly create an unpinned one — the kind of boundary I argued for in modularizing Android apps. Pick exactly one mechanism. Configuring both the XML and CertificatePinner with different pin sets is a reliable way to get a connection that mysteriously fails one check while passing the other.
Pin the SPKI, not the leaf certificate
This is the single most important decision, so it gets its own section. Pin the public key, not the certificate.
A leaf certificate is reissued every time it is renewed — typically every 90 days with modern automated CAs, and immediately on any revocation. If your pin is the hash of that certificate, every renewal is a breaking change you must ship an app update for. Pin the SPKI instead and the certificate can be reissued as often as the CA likes: as long as the CSR reuses the same key pair, the SPKI is identical and the pin still matches.
Compute the base64 SPKI hash straight from the live server:
openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform der \
| openssl dgst -sha256 -binary \
| openssl enc -base64
Run the same pipeline against the backup key's certificate (or its CSR's public key) to get the second pin. For OkHttp you can also deliberately ship one wrong pin, run once on a trusted network, and copy the correct hashes out of the exception message it throws — just never do that behind a debugging proxy.
Always configure a backup pin
A single pin is a time bomb. The moment you have to change the pinned key — key compromise, a CA migration, an HSM swap — a one-pin app has no matching key left and cannot connect. Your only recovery is an emergency release that every user must install before they can use the app again, and the ones who do not update are simply locked out.
The backup pin is the escape hatch: a second key, generated now and kept offline, whose hash ships alongside the active one. Because the app already trusts that key, you can cut over to it server-side with zero client changes. Every production pin set should contain at least two pins, and both should be keys you control.
A rotation strategy that never bricks the app
Backups only work if you keep replenishing them. The discipline is a rolling window:
- Ship two pins: key A (serving traffic) and key B (backup, offline).
- When you rotate, switch the server to present key B. The app already pins B, so nothing breaks. No release required.
- In your next app release, generate a fresh key C, add its pin, and drop the now-retired A. You are back to two live pins — current plus backup — and the cycle repeats.
The rule underneath: an app version must always pin a key that is still valid by the time the next version has rolled out to most users. Size your pin-set expiration and your release cadence so that is always true. Monitor certificate and key expiry the way you monitor any production dependency, and rotate on your schedule, not the CA's.
Common mistakes
- Pinning the leaf certificate. Breaks on every renewal. Pin the SPKI.
- No backup pin. Turns any key change into a forced-update outage. Always ship a second pin.
- No rotation plan. Two pins with no process to replenish them is just a slower outage. Automate the rolling window.
- Enforcing pins in debug builds. A release-level pin set blocks Proxyman and Charles, so developers cannot inspect their own traffic. Keep pinning in release, and add a debug-only trust anchor that applies only when the build is debuggable:
<network-security-config>
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
<!-- pin-set as above applies to release -->
</network-security-config>
- A pin-set
expirationin the past. Silently disables pinning — the app keeps working, so nobody notices the protection is gone. Always set a future date and advance it on each release.
Done right, pinning is boring: a key you control, a backup beside it, and a calendar reminder to rotate. Done wrong, it is the most self-inflicted outage in mobile security. The API is a five-minute job; the operational plan is the whole thing.