Network Security Explained
SECURITY › Security
Network security interviews start with one blunt fact: since Android 9 (API 28), **cleartext HTTP traffic is blocked by default**. If your app's targetSdk is 28 or higher and you make a plain http:// request with no configuration in place, the platform refuses the connection before a single byte goes over the wire.
This flips the old default. Before API 28, cleartext was allowed unless you opted out. Now HTTPS is the floor, and you opt *in* to cleartext, scoped to exactly the domains that need it, through a declarative XML file called the network security configuration.
The mental model for this whole topic: **TLS is the default, exceptions are explicit and narrow, and almost everything here is configured, not coded.**
The network security config lives at res/xml/network_security_config.xml and is built from two building blocks: base-config and domain-config.
base-config sets the default policy for **everything not matched elsewhere**. domain-config overrides that policy for one or more specific <domain> entries, and each entry's includeSubdomains attribute decides how far the match reaches: includeSubdomains="false" matches only that exact host, while includeSubdomains="true" matches the domain **and everything below it** (api.example.com, cdn.example.com, and so on). The most specific matching rule wins; anything not named in any domain-config falls through to base-config.
<network-security-config>
<base-config cleartextTrafficPermitted="false"/>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">legacy.example.com</domain>
</domain-config>
</network-security-config>
This lets you keep the app locked down everywhere while carving out one narrow, auditable exception, legacy.example.com exactly, not any subdomain of it, for an endpoint that genuinely can't move to HTTPS yet.
There's a trust change that predates all of this XML, and interviewers test it separately from cleartext blocking: since Android 7.0 (API 24), apps trust **only the system CA store** by default. Any CA a user installs through Settings, the kind an enterprise MITM proxy or a security researcher relies on, is silently ignored unless the app explicitly opts in.
That opt-in lives in the same trust-anchors block as everything else in this topic: add <certificates src="user"/> alongside the default src="system" and the app will trust user-installed CAs too. This is exactly the mechanism that blocks a common debugging setup, install a proxy's root CA on the device, expect the app to trust it, and get nothing until the app's network security config says so.
<base-config>
<trust-anchors>
<certificates src="system"/>
<certificates src="user"/>
</trust-anchors>
</base-config>
TLS gives you two guarantees at once: **encryption** (nobody on the wire can read the payload) and **server authentication** (you're actually talking to the server you think you are). Interviewers usually probe the second one, because that's what a man-in-the-middle attack targets.
Here's the mechanism: the server presents a certificate chain. Your device checks that the chain terminates in a Certificate Authority it already trusts (the system CA store, by default), *and* that the certificate's hostname matches the host you requested. Both checks must pass.
A MITM attacker sitting on the network, even a hostile Wi-Fi access point, can't just intercept and read your traffic. They'd need to present a certificate that your device accepts as genuinely belonging to that hostname, which normally means compromising a trusted CA or getting you onto a device that already trusts a rogue one.
Even a perfectly configured HTTPS connection leaks more than it feels like it should. TLS encrypts the request and response, the path, headers, and body, but the destination itself is visible on the wire in several ways: DNS resolution for the hostname typically happens in cleartext before the connection even starts, the **SNI** (Server Name Indication) field in the TLS handshake announces which hostname you're connecting to in plaintext, and the destination IP address sits in every packet.
None of that requires breaking encryption, a network observer just watches metadata: which hosts you talk to, how often, how much data moves, and roughly when. That's why sensitive routing decisions, not just payloads, sometimes need their own protection, a VPN or encrypted DNS, since TLS alone was never built to hide *who* you're talking to, only *what* you say.
Not every networking API gives you hostname verification for free. SSLSocket is the low-level building block, and it only validates the certificate chain up to a trusted CA. It does **not** check that the certificate's hostname matches the host you connected to, that part is left to you:
val socket = SSLSocketFactory.getDefault()
.createSocket("api.example.com", 443) as SSLSocket
socket.startHandshake()
// chain trusted, but hostname was never checked
Skip that step and any certificate valid for *any* hostname, including one an attacker legitimately owns, will pass. HttpsURLConnection (and libraries like OkHttp) verify the hostname automatically, which is exactly why they're the recommended default over raw sockets.
**Certificate pinning** tightens trust further: instead of accepting any certificate a trusted CA issues, you pin the app to a specific key. Android pins are declared in a pin-set and contain a Base64-encoded **SHA-256 hash of the certificate's SubjectPublicKeyInfo (SPKI)**, not a hash of the whole certificate.
Pinning the public key rather than the certificate matters: a certificate can be reissued (new serial number, new expiry) while keeping the same key pair, and the pin still matches. Pin the whole cert and routine renewal breaks every installed app.
<pin-set>
<pin digest="SHA-256">7HIpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRhJ3Y=</pin>
</pin-set>
The pin-set examples so far pin the **leaf** certificate's key, the one issued directly to your domain. Leaf certs get reissued often, every renewal, every CA switch, so leaf pinning means you're managing backup pins constantly.
The alternative is pinning higher in the chain: the **intermediate** or **root CA's** public key instead. That key stays stable across routine leaf renewals, since the CA reuses it to sign many leaf certs over time, so your pin survives certificate rotation without needing a fresh backup pin every time. The tradeoff is blast radius: pin the root and you're trusting *any* certificate that CA ever signs, for *any* domain, not just yours, so a compromise anywhere in that CA's issuance widens what an attacker could forge against you. Most teams land on pinning an intermediate as the practical middle ground between the two extremes.
Pinning has a sharp edge: **rotation**. If you pin only the currently deployed key and that key is ever replaced, every already-installed app that enforces the pin loses connectivity to that host instantly, since the new certificate's SPKI no longer matches.
The standard defense is a **backup pin**: a second <pin> entry for a key you've generated but haven't deployed yet. When you eventually rotate, you switch the server over to the key the backup pin already covers, and installed apps keep working without needing an update first.
<pin-set>
<pin digest="SHA-256">7HIpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRhJ3Y=</pin> <!-- current -->
<pin digest="SHA-256">r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E=</pin> <!-- backup -->
</pin-set>
The other pinning knob is **expiration**. A pin-set can carry an expiration date, after which the OS stops enforcing pins for that domain entirely and falls back to normal CA trust.
This exists to protect users who never update: without it, an app that outlives its ability to receive an update could get permanently bricked for that host the moment a rotation happens. But it's a real tradeoff, once the date passes, an attacker with a CA-trusted (but not pinned) certificate can intercept traffic exactly as if pinning had never been configured. It doesn't fail loud, it just quietly stops helping.
<pin-set expiration="2026-01-01">
<pin digest="SHA-256">7HIpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRhJ3Y=</pin>
</pin-set>
Everything so far has been declarative XML, but most real apps using OkHttp reach for its programmatic equivalent instead: CertificatePinner. You register one or more SPKI pins per hostname, the exact same SHA-256 digest as the XML pin-set, just written with a sha256/ prefix, and OkHttp enforces them on every TLS handshake made through that client.
val pinner = CertificatePinner.Builder()
.add("api.example.com", "sha256/7HIpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRhJ3Y=")
.add("api.example.com", "sha256/r/mIkG3eEpVdm+u/ko/cwxzOMo1bk4TyHIlByibiA5E=")
.build()
val client = OkHttpClient.Builder()
.certificatePinner(pinner)
.build()
The two approaches aren't mutually exclusive, the XML config still governs cleartext and trust-anchors app-wide, but CertificatePinner keeps pinning logic in code, easy to unit test, and simple to vary per build flavor without touching resources.
Two final pieces close out the topic. First, **debug-only trust**: to intercept your own app's traffic during development (say, with a proxy using a self-signed CA), you can add that CA under <debug-overrides>. It only takes effect when android:debuggable="true", and Play rejects debuggable release builds, so it's safe to leave in the config permanently:
<debug-overrides>
<trust-anchors>
<certificates src="@raw/my_debug_ca"/>
</trust-anchors>
</debug-overrides>
Second, and more important than any XML: **no configuration protects a secret embedded in the client**. An APK can always be decompiled and inspected, so API keys and third-party credentials must live on a backend that proxies the call, with the app authenticating to *your* server and receiving a short-lived token, never the underlying secret itself.