Network Security Explained
SECURITY › Security
Start with one blunt fact: since Android 9, API level 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 network security configuration in place, the platform refuses the connection before a single byte goes over the wire.
The key detail interviewers probe is what actually governs that default. It is not the version of Android the device is running, it is the app's targetSdk. An app that still targets API 27 or lower keeps the old, pre-Android-9 default of allowing cleartext, even when it runs on a brand new device with the latest OS. Bump targetSdk to 28 and that same app, unchanged otherwise, starts refusing plain HTTP.
The mental model for this whole topic: TLS is the default, exceptions are explicit and narrow, and almost everything here is configured through XML, not coded.
The network security config lives at res/xml/network_security_config.xml and is built from two 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 the most specific matching rule wins, anything not named in any domain-config falls through to base-config.
Each domain entry also carries an includeSubdomains attribute that decides how far its match reaches. Left false, or omitted, it matches only that exact host. Set to true, it matches the named domain and everything underneath it, api.example.com, cdn.example.com, all of it, from a single line.
Put that anatomy to work on the classic interview scenario: an endpoint genuinely has to stay on plain HTTP, maybe a legacy internal service, and the app now fails every request to it with a cleartext-not-permitted error.
The correct fix is narrow: add a domain-config entry for exactly that host with cleartextTrafficPermitted set to true, and leave base-config denying cleartext for everything else. That keeps the exception auditable and contained to one domain instead of quietly widening what the whole app will accept.
<network-security-config>
<base-config cleartextTrafficPermitted="false"/>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">legacy.example.com</domain>
</domain-config>
</network-security-config>
A manifest-wide flag, a targetSdk downgrade, or deleting the config file entirely would all technically make the error go away, but each one reopens cleartext far beyond the one endpoint that actually needed it.
There is a second trust change that predates all of this XML and gets tested separately from cleartext blocking: since Android 7.0, API level 24, apps trust only the system CA store by default. Any certificate authority a user installs manually through Settings, the kind an enterprise 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. Adding a certificates entry with src set to user, alongside the default src of system, makes the app trust user-installed CAs too. This is exactly the mechanism that trips people up during debugging: install a proxy's root CA on the device, expect the app to trust it, and get nothing until the network security config says so.
TLS gives you two guarantees bundled together: encryption, so nobody on the wire can read the payload, and server authentication, so you actually know you are talking to the server you think you are. Interviewers usually probe the second one, because that is exactly what a man in the middle attack targets.
Here is the mechanism. The server presents a certificate chain, and the device checks two things: that the chain terminates in a certificate authority it already trusts, and that the certificate's hostname matches the host that was requested. Both checks have to pass.
An attacker sitting on the network, even a hostile Wi-Fi access point, cannot just intercept and read your traffic. To succeed they would need a certificate the device accepts as genuinely belonging to that hostname, which normally means compromising a trusted CA, or finding a client that skips the hostname check.
Even a perfectly configured HTTPS connection leaks more than it feels like it should. TLS encrypts the request and response, the path, the headers, and the 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 field, server name indication, announces which hostname you are connecting to in plaintext during the TLS handshake itself. And the destination IP address sits in every packet regardless of encryption.
None of that requires breaking the encryption. A network observer just watches metadata: which hosts you talk to, how often, roughly how much data moves, and roughly when. That is why sensitive routing decisions sometimes need their own protection, a VPN or encrypted DNS, since TLS alone was never built to hide who you are talking to, only what you say to them.
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 actually connected to, that part is left entirely to you.
val socket = SSLSocketFactory.getDefault()
.createSocket("api.example.com", 443) as SSLSocket
socket.startHandshake()
// chain is trusted here, but hostname was never checked
Skip that step and a certificate valid for any hostname, including one an attacker legitimately owns for their own domain, will pass the handshake against your host. HttpsURLConnection, and libraries built on it like OkHttp, verify the hostname automatically, which is exactly why they are the recommended default over raw sockets.
Certificate pinning tightens trust one step further. Instead of accepting any certificate a trusted CA happens to issue, you pin the app to a specific key. Android pins live in a pin-set, and each one is a Base64-encoded SHA-256 hash, not of the whole certificate, but of the certificate's SubjectPublicKeyInfo, its public key.
That distinction is deliberate. A certificate can be reissued, new serial number, new expiry date, while keeping the exact same key pair, and the pin still matches. Pin the whole certificate instead of just the key, and routine renewal breaks every installed app for no good reason.
<pin-set>
<pin digest="SHA-256">7HIpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRhJ3Y=</pin>
</pin-set>
Pinning has a sharp edge: rotation. If you pin only the currently deployed key and nothing else, and that key is ever replaced, whether because the cert was reissued or the CA changed, every already-installed app enforcing the pin loses its ability to reach that host, since the new certificate's public key digest no longer matches anything in the pin-set.
The standard defense is a backup pin: a second pin entry for a key you have already generated but have not deployed to the server 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.
The other pinning knob is expiration. A pin-set can carry an expiration date, and once that date passes, the OS stops enforcing pins for that domain entirely and quietly falls back to ordinary 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 locked out of that host the moment any rotation happens. But the tradeoff is real: once the date passes, an attacker holding a CA-trusted, but not pinned, certificate can intercept traffic exactly as if pinning had never been configured at all. It does not fail loudly, it just quietly stops helping.
<pin-set expiration="2026-01-01">
<pin digest="SHA-256">7HIpactkIAq2Y49orFOOQKurWxmmSFZhBCoQYcRhJ3Y=</pin>
</pin-set>
The pin-set examples so far all pin the leaf certificate's key, the one issued directly to your domain. Leaf certificates get reissued often, every renewal, every CA switch, so leaf-only pinning means managing fresh backup pins constantly.
The alternative is pinning higher in the chain: the intermediate or root CA's public key instead of the leaf's. That key stays stable across routine leaf renewals, since the CA reuses it to sign many leaf certificates over time, so the pin survives certificate rotation without a fresh backup pin every cycle. The tradeoff is blast radius: pin the root and you are trusting any certificate that CA ever signs for any domain, not just yours, so a compromise anywhere in that CA's issuance widens what could be forged against you. Most teams land on pinning an intermediate as the practical middle ground.
Everything covered 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 are not 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 pieces close out the practical side of this topic, starting with debug-only trust. To intercept your own app's traffic during development, say with a proxy that uses a self-signed CA, you add that CA under a debug-overrides block instead of touching your real trust anchors.
It only takes effect when android:debuggable is true, and Play rejects debuggable release builds outright, so it is safe to leave in the config permanently, there is no build variant where it can reach a real user.
<network-security-config>
<debug-overrides>
<trust-anchors>
<certificates src="@raw/my_debug_ca"/>
</trust-anchors>
</debug-overrides>
</network-security-config>
The second, and more important than any XML in this whole topic: no configuration protects a secret embedded in the client. An APK can always be decompiled and inspected, string resources read, native libraries disassembled, memory dumped at runtime, so an API key or third party credential shipped inside the app is recoverable by anyone who wants it badly enough. Encryption of the string, obfuscation with ProGuard or R8, hiding it in a native library, none of that changes the underlying fact.
The correct architecture keeps the real secret server side. Your app authenticates to your own backend, that backend holds the third party credential and proxies the call, and the client receives only a short-lived token scoped to what it actually needs, never the underlying secret itself. That is the pattern to reach for any time an interviewer asks where a key should live.