Pinning on Android is a few lines of configuration, either in Network Security Config or in OkHttp's CertificatePinner. Those lines are the easy part. The hard part is choosing which key to pin, always keeping a backup, and rotating keys without locking out users who are still on an old app version. In this post I go through both mechanisms, those operational decisions, and what pinning does not protect against.
I've worked with pinning in a banking app that also has NFC/eID flows like the ones in reading an eID chip on Android.
What pinning protects against
By default, Android validates the server certificate against the system trust store. Since Android 7.0, apps targeting API 24 or higher don't trust user-installed CAs unless they opt in. Pinning adds a second condition: the validated chain must also contain a public key you chose in advance.
This protects against an attacker who holds a valid certificate for your domain from a CA in the system store, for example through a mis-issuing or compromised CA. The price is availability. A pin mismatch is a hard connection failure with no fallback. A wrong pin, a missing backup, or a server key change you didn't plan for, and the app can't reach your backend until users install an update. That is why Android's Network Security Config documentation treats pinning cautiously and insists on backup pins. Pinning is a deliberate trade-off, not something every app should turn on by default.
Network Security Config
The declarative option is an XML file referenced from the manifest (available from Android 7.0, API 24). It applies to connections that go through the platform's default trust manager: HttpsURLConnection, and OkHttp/Retrofit as long as you haven't replaced the default TrustManager. WebView uses Chromium's own network stack, and native code with its own TLS library doesn't go through this path either, so don't assume the pins cover them.
<!-- 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: key currently in use -->
<pin digest="SHA-256">k3hBDZ8m0cJ8yq1Yd0pPtQx2cHjWn7p9vF4sL1aXuGk=</pin>
<!-- Backup key, prepared in advance, not serving traffic yet -->
<pin digest="SHA-256">YwFp2nL8qR6tZ3vK1mD9xS0cJ7hG4bN5aU8eW2oP1rM=</pin>
</pin-set>
</domain-config>
</network-security-config>
<application android:networkSecurityConfig="@xml/network_security_config">
The pin values here are placeholders; generate yours from your own keys (see below). A few details:
digest="SHA-256"is the only supported digest. The value is the base64 SHA-256 hash of the certificate's SubjectPublicKeyInfo (SPKI), not of the whole certificate.- The check passes if at least one certificate in the validated chain (leaf, intermediate or the trust anchor) has a pinned key.
- With
includeSubdomains="true", the same pin set applies to every subdomain ofapi.example.com. expirationuses theyyyy-MM-ddformat. From that date on, the pins are no longer enforced and the connection falls back to normal CA validation. This keeps an old, never-updated app from breaking, but the protection also disappears quietly. Treat the date as part of your release process and move it forward with each version.
OkHttp CertificatePinner
If you want pinning in code, where it's easier to test and to manage per environment, use CertificatePinner. The values are the same base64 SHA-256 SPKI hashes with a sha256/ prefix:
val certificatePinner = CertificatePinner.Builder()
.add(
"api.example.com",
"sha256/k3hBDZ8m0cJ8yq1Yd0pPtQx2cHjWn7p9vF4sL1aXuGk=", // current
"sha256/YwFp2nL8qR6tZ3vK1mD9xS0cJ7hG4bN5aU8eW2oP1rM=", // backup
)
.build()
val client = OkHttpClient.Builder()
.certificatePinner(certificatePinner)
.build()
Hostname patterns follow these rules:
api.example.commatches only that exact host;www.api.example.comneeds its own entry.*.example.commatches exactly one label:api.example.com, but nota.b.example.com.**.example.commatches subdomains at any depth, andexample.comitself.- If a host matches both an exact and a wildcard pattern, the pins from both are accepted.
OkHttp doesn't check the pins against the raw chain the server sends. It first "cleans" the chain: using the trust manager, it builds the trusted path from the leaf to a trust anchor and drops unrelated certificates. The pins are compared against that cleaned chain. That means you can pin a root CA key even if the server never sends the root, and an attacker can't satisfy the pin by adding an extra certificate to the handshake.
Keep the pinned client in one place, for example a :core:network module, so that no other module quietly creates an unpinned OkHttpClient. I wrote about this kind of boundary in modularizing Android apps. If you use both Network Security Config and CertificatePinner, both checks run; generate the pins from a single source, otherwise the two lists will drift apart and you'll get connections that pass one check and fail the other.
Which key to pin: leaf, intermediate or CA
Both APIs pin public keys (SPKI), not whole certificates, so certificate renewal alone doesn't break a pin. The pin only breaks when the key changes. The real decision is which key in the chain you pin:
- Leaf key: the tightest option, since only your own key matches. It survives renewal only if each new certificate is issued for the same key pair. Many ACME clients generate a new key on every renewal by default (certbot, for example, needs
--reuse-key), and with a CDN or a managed load balancer you may not control the key at all. - Intermediate CA key: survives leaf key changes, but accepts any certificate that intermediate issues for your host. CAs rotate intermediates, and some issue from several in parallel, so you have to follow your CA's announcements.
- Root CA key: changes least often and offers the least protection: it only narrows trust from the whole system store down to that CA.
If you control key management, pin your own leaf key plus a backup key. If you don't, pin intermediate or root keys, and add a backup pin for a key from a second CA so you can move to it if needed.
To compute the hash from a 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
This gives you the leaf key's hash; for the intermediates, use -showcerts and run the same steps on each certificate. Run it from a network you trust, because behind a TLS-inspecting proxy you'll get the proxy's key. For a backup key that isn't deployed yet, compute the pin from the key file:
openssl pkey -in backup.key -pubout -outform der \
| openssl dgst -sha256 -binary \
| openssl enc -base64
With OkHttp you can also put a deliberately wrong pin in a local test build: the SSLPeerUnverifiedException message lists the sha256/ values of the cleaned chain. Do this only on a trusted network, never behind a debugging proxy.
Always ship a backup pin
With a single pin, the day the pinned key has to change (a key compromise, a CA switch, an infrastructure change) the app has nothing left to match. The only fix is an emergency release, and users who don't update can't connect until they do.
A backup pin prevents that. You prepare a second key in advance (a key pair you generate and keep safe, or a key from a second CA) and ship its hash next to the active one. Since the app already accepts that key, you can move the server to it without an app release. Each pin set should contain at least two pins.
Rotating without locking users out
A backup only helps if you replace it once it's used. The rotation works like a rolling window:
- Ship two pins: key A (in use) and key B (backup).
- When you rotate, get a certificate for key B and deploy it on the server. The app already pins B, so no release is needed.
- In the next app release, prepare a new key C, add its pin and remove A. The pin set is back to current plus backup.
Before each server-side switch, check one thing: does every app version you still support pin the new key? If you're about to move to C and there are still meaningful numbers of users on the version that only pins A and B, either wait or use your force-update mechanism. This is also where expiration matters (Network Security Config only; CertificatePinner has no expiry): for very old versions you can't update, it makes them fall back to normal CA validation at the end instead of failing. Follow certificate and key dates like any other production dependency, and rotate on your own schedule rather than the CA's.
In the app, pin failures show up as an SSLPeerUnverifiedException from OkHttp or an SSLHandshakeException from the platform. Report them to your crash or analytics tooling separately from other network errors, so a wrong pin shows up in monitoring before it shows up in user complaints.
Debug builds and user CAs
Pinning in release shouldn't stop you from inspecting your own traffic with Proxyman or Charles in development. For Network Security Config, a debug-overrides block is enough:
<network-security-config>
<domain-config>
<!-- domain and pin-set as above -->
</domain-config>
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>
This block only applies when the build is debuggable (android:debuggable="true") and is ignored in release builds. Pins aren't enforced for chains that end in these debug-only anchors, so the proxy's user CA works in debug while the release pin set stays as it is.
OkHttp's CertificatePinner doesn't know about debug-overrides. The cleaned chain now ends at the proxy's CA, the pins don't match and the request fails. So don't install the pinner in debug builds:
// BuildConfig requires buildFeatures { buildConfig = true } on AGP 8+
val certificatePinner = if (BuildConfig.DEBUG) {
CertificatePinner.DEFAULT
} else {
CertificatePinner.Builder()
.add("api.example.com", PRIMARY_PIN, BACKUP_PIN)
.build()
}
Here PRIMARY_PIN and BACKUP_PIN are the sha256/ strings from above. Never trust user CAs in release builds; apps targeting API 24+ don't trust them by default, so don't add them back.
Certificate Transparency
Certificate Transparency (CT) tackles the same problem from a different angle. Instead of saying "only these keys", it requires a certificate to be published in public logs. A CA can still mis-issue a certificate for your domain, but clients that enforce CT reject it unless it has been logged, so using it against those clients leaves a public record. You can monitor CT logs for certificates issued for your domains. Chrome and Apple platforms require CT for publicly trusted certificates. On Android, app traffic hasn't traditionally been checked by the platform; newer releases are adding CT support, so check the Network Security Config documentation for what your target SDK does. OkHttp has no built-in CT check; third-party libraries add one.
CT doesn't stop a mis-issued certificate from being used before someone notices it, but it doesn't carry the risk of locking users out either. For many apps, CT monitoring plus CAA DNS records that limit which CAs may issue for your domain is a reasonable alternative, with pinning kept for the highest-risk hosts.
What pinning doesn't stop
Pinning protects users on a device that isn't compromised from a network attacker. It doesn't protect your app from someone running it on a device they control. On a rooted device, or with a repackaged APK, tools like Frida and objection hook the TLS checks inside the app at runtime (the platform trust manager, OkHttp's CertificatePinner) and make them always pass. A repackaged app can also simply replace its Network Security Config.
So pinning isn't API protection. Put authorization and abuse checks on the server, don't ship secrets in the app, and use signals like Play Integrity and root/hook detection, the kind I collected in SecureCheck, as layers that raise the cost of an attack, knowing they can also be bypassed.
Frequently asked questions
- Should I pin the leaf, the intermediate or the root CA key?
- Both Network Security Config and OkHttp pin SPKI (public key) hashes, so the real choice is which key in the chain. The leaf key is the tightest but only works if you control it and reuse it across renewals. Intermediate and root CA keys survive leaf changes but accept any certificate that CA issues for your host, and CAs do rotate intermediates.
- Why does a pin set need a backup pin?
- With a single pin, any change to the pinned key (a compromise, a CA switch, an ACME client that generates a new key on renewal) leaves the app with nothing to match, and only an app update can fix it. A backup pin for a key you have prepared in advance lets you switch the server without touching the app.
- What happens when the pin-set expiration date passes?
- From that date on, Network Security Config stops enforcing the pins and falls back to normal CA validation. The app keeps working, but the pinning protection is gone without any visible sign, so the date has to be moved forward in each release.
- How do I keep pinning from blocking Proxyman or Charles in debug builds?
- For Network Security Config, add a debug-overrides block that trusts user certificates; it only applies to debuggable builds, and pins are not enforced for chains that end in those anchors. OkHttp's CertificatePinner ignores that block, so don't install the pinner in debug builds.