Prerequisites
Read these first if Charles / SSL proxying, Android network security config, or TLS are new.
- Charles — SSL certificates — debug CA install; why release builds go silent
- Android — Network security configuration — user CAs, debug overrides, pinning on the phone path
- MDN — TLS — why HTTPS is opaque without a trusted (or debug) CA
- MDN — Proxy servers and tunneling — what a proxy hop is
Postman was green. The phone was not.
You paste a failing mail sync into Postman. It returns 200. On the device the UI spins, then shows a stale folder. Until someone puts a middle box on the path, the debate stays superstition: “Android bug,” “backend flake,” “Wi‑Fi.”
A forward proxy (Charles, Proxyman, mitmproxy) is that middle box: the phone sends traffic to a machine you control; that machine forwards to the real API and can log, delay, rewrite, or stub what it sees. That is not the same as a reverse proxy in front of origins (nginx, gateway, CDN). Mixing those words in a design review is how two teams talk past each other for an hour.
The interesting failures start after you “put it on Charles.”
Charles shows nothing
The first wrong guess is “Charles is broken.” Usually one of these is true:
- Wrong hop. Emulator often reaches the host as
10.0.2.2:8888. A physical phone needs the laptop’s LAN IP, same Wi‑Fi, and no captive portal fighting you. - Wrong build. On Android 7+, apps do not trust user-installed CAs by default. SSL proxying only works for apps you control, with a debug
network_security_configthat trusts user CAs — typically underdebug-overridesso release stays strict. A dogfood release binary will look “quiet” on Charles forever. - App bypasses the system proxy. OkHttp (and some SDKs) can ignore the device HTTP proxy. Charles sees zero; Postman still looks fine because it was never on that path.
<!-- res/xml/network_security_config.xml — debug builds only -->
<network-security-config>
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>
When the capture is empty, the next question is not “is the API up?” — it is “did this process actually route through the middle box?”
Decrypt works — then pinning kills it
HTTPS through a normal hop is gibberish. Charles works by becoming a debug CA: you install its root, it mints per-host leaves, decrypts for the UI, re-encrypts upstream. That is deliberate MITM — correct in lab, wrong as a production trust model.
Certificate pinning will still reject Charles’s forged leaf even when the user CA is trusted — by design. The symptom looks like “proxy breaks the app,” which is half true: the proxy is doing its job; the pin is doing its job. Unpin or ship a debug build without pins. Do not ship “trust user CAs” or disabled pinning in release.
For example, in a mail app, auth headers and message bodies show up in the capture the moment SSL proxying works. Treat that session like a production log. Scrub before Slack.
The capture that changes the bug
Once traffic is visible, the interesting bugs stop being “Android vs backend”:
- Response is 200 but body is empty / wrong shape — UI code was never the root cause.
- Intermittent 5xx during a backend rollout — aggressive client retries look like an app hang (retry storms).
- Throttle the link in Charles and watch sync/retry policy: many “Compose jank” tickets are radio + backoff, not
LazyColumn.
Rule of thumb - if Postman and the phone disagree, get a capture before you rewrite the list. If Charles is empty, debug the hop and the build — not the Compose state.
Charles proves what the wire did. Empty captures and pinning fights are not tool trivia; they are why teams waste a day on the wrong layer.
References
- API up, still broken on phones (this site)
- Retry storms (this site)
- Certificate pinning (OWASP) — why Charles goes dark on release builds