- Published on
When the Security Policy Blocks on the Client: iOS ATS and the Triple Invisibility Behind "Works on Simulator, Fails on Device"
- Authors

- Name
- Jack Qin
When we debug network problems we hold a deeply ingrained assumption: the request goes out, and failure happens on the wire or at the other end, so there's always a log, a packet capture, an error code to inspect. App Transport Security (ATS) breaks exactly this assumption — it's a security policy enforced on the client, before the request ever leaves the device. A request it rejects never hits the wire, so the server has no trace of it, and every "look at the wire and the other end" debugging technique fails at once.
This post isn't about fixing one particular network bug; it's about reducing "runs fine on the simulator, the feature vanishes the moment it's on a real device" to a clean ledger: why it's the result of three layers of invisibility stacked together, what debugging signal each layer eats, and why the true source of the fix is a config file you think a reload will apply but which was actually frozen at build time. Understand this ledger and you're facing not just ATS, but a whole class of "security/policy silently blocks on the client side" problems.
Three layers of invisibility: why the symptom is completely invisible
The failure manifests in all kinds of ways — a conditionally rendered button (e.g. "Sign in with Microsoft," which depends on a providers endpoint) silently fails to render; a list stays empty with no error message; the login page loads, but every request fails in the same way. Most confusing of all: nothing shows up in the dev tools. Three mechanisms stack in layers, eating every signal that should have appeared:
- iOS's NSURLSession rejects the request before it leaves the device — so the backend has no log at all. The first signal (server-side observability) is gone.
- RN's
fetchlayer throws it as an ordinary network error, and that error is usually caught by an outertry/catch, falling back to a "safe" default. The second signal (error propagation) is swallowed by fail-soft. - This fallback default looks just like a legitimate "feature is disabled" state — providers endpoint fails → fall back to "credentials-only login" → the Microsoft button is supposed to be absent. The third signal (UI anomaly) is gone too, because the failure is disguised as a normal product state.
Each layer is individually "reasonable": client-side enforcement is ATS's design; fail-soft is routine defensive network code; conditionally rendering by the providers result is correct product logic. But stacked together, they translate a network-layer rejection into a feature flag that looks normal — which is exactly why this class of problem is so hard to debug.
The mechanism: what ATS rejects, and why the simulator dodges it
iOS's default ATS policy blocks cleartext HTTP requests. The simulator is fine because it goes through localhost, for which the project already has an exception configured; a real device points at the same backend but goes over a LAN IP (192.168.x.x, 10.x.x.x, 172.16-31.x.x), for which there's no exception, so NSURLSession cuts it off locally. That's the root of the "works on simulator, fails on device" split — the two hit different (or missing) entries in the ATS exception table.
There's a key clue here that qualifies it in 30 seconds: the same URL opens fine in Safari on the real device. Safari has a more permissive set of ATS settings in dev. So "Safari works, the app doesn't" is practically the fingerprint of ATS blocking — it cleanly separates "a network-path problem" from "the app's own policy problem": the network is fine (Safari proves it), so it's the app's ATS doing the blocking.
The true source: the config is frozen at build time
The fix direction isn't hard — add a LAN exception to ATS. But the more hidden, most-often-stuck-on part is at which moment this config takes effect.
app.json is the single source of truth for config. Expo prebuild compiles it at build time into ios/<AppName>/Info.plist, and the running .app carries the Info.plist baked in at build time. In other words, the lifecycle of ATS config belongs to the native build, not to JS — and the Metro hot reload we iterate on daily only touches JS.
| Change | Operation required |
|---|---|
Edit app.json's ATS dictionary | pnpm expo prebuild --platform ios + rebuild + reinstall the app |
Edit ios/<AppName>/Supporting/Expo.plist directly | Rebuild only (but prebuild --clean will overwrite your change — the source must live in app.json) |
Reload Metro / press r | No effect. Metro only ships new JS; native config froze at install time |
| Cold-start the app | No effect. Same as above |
"I changed app.json, why still no?" is most commonly answered by this table: you only reloaded Metro, and the native Info.plist never moved. This is a classic misalignment between the config layer and the iteration layer — the knob you think you're tuning (JS hot reload) and the knob the problem lives in (build-time native config) are two different pipelines.
The fix, and the granularity judgment it forces out
Add NSAllowsLocalNetworking in apps/mobile/app.json:
{
"expo": {
"ios": {
"infoPlist": {
"NSAppTransportSecurity": {
"NSAllowsLocalNetworking": true, // ← required for LAN debugging
"NSExceptionDomains": {
"localhost": {
"NSExceptionAllowsInsecureHTTPLoads": true,
},
},
},
},
},
},
}
What's actually worth recording isn't this JSON, but the granularity tradeoff behind it — there are three granularities of ATS exception, and choosing one is pricing the gap between "security scope" and "debugging convenience":
| Key | Effect | When to use |
|---|---|---|
NSAllowsLocalNetworking: true | Allows cleartext HTTP to RFC 1918 private ranges (10/8, 172.16/12, 192.168/16) and .local mDNS hosts. Required for a real-device debug build connecting to a Mac on the same Wi-Fi | Dev only, leave on |
NSExceptionDomains.localhost.…InsecureHTTPLoads: true | Allows cleartext HTTP to localhost specifically | Needed only by the simulator (shared host loopback) |
NSAllowsArbitraryLoads: true | Allows cleartext HTTP to any domain (including the public internet) | Don't. Scope too broad, Apple review flags it |
NSAllowsLocalNetworking is the right-sized tier — narrower than NSAllowsArbitraryLoads (it doesn't open the public internet), and wider than enumerating each dev machine's IP (IPs change with DHCP/network and differ per person). Don't hardcode a LAN IP into NSExceptionDomains: a lease change or a network switch breaks it.
After the change, first verify the config really landed on the device, then go look at app code:
/usr/libexec/PlistBuddy -c "Print :NSAppTransportSecurity" \
apps/mobile/ios/<AppName>/Info.plist
If NSAllowsLocalNetworking is missing, prebuild didn't run, or the build cache stuffed in an old .app — rebuild before continuing.
Reverse the debugging order
The biggest time sink in this class of problem is diving into app code before confirming the network path. The right order is to falsify layer by layer from outside in, with app code last:
- On the Mac,
lsof -nP -iTCP:<port> -sTCP:LISTEN— is the backend listening on*:<port>(all interfaces) or only127.0.0.1? - On the Mac,
curl http://<LAN-IP>:<port>/<endpoint>— does the backend reach on the LAN address? - In Safari on the device, open
http://<LAN-IP>:<port>/<endpoint>— is the Mac↔device path open (same Wi-Fi, no AP isolation)? - Run
PlistBuddy -c "Print :NSAppTransportSecurity"on the builtInfo.plist— is the ATS exception there? - Only after all four pass do you look at app code.
Mac curl works, device Safari works, only the app fails → it's ATS. This decision tree itself matters more than any single command: it forces you to leave a clear "pass/fail" verdict at every layer, instead of guessing blindly at the most expensive layer (business code).
The transferable layer
Set aside iOS and Expo's specific APIs, and the truly transferable lesson is:
Any security/policy layer enforced on the client side makes "failure" happen outside your observability. ATS is just one example — the browser's CORS/CSP, mobile certificate pinning, an enterprise MDM's network policy, a local firewall — all share this property: the rejection happens before the request hits the wire, the server has no trace, the error often gets swallowed by upper-layer fail-soft, and it ultimately disguises itself as a "normal state." To debug this class of problem, instead of hunting in business code for who returned empty, first ask: at which layer did this failure actually happen? Could it have been blocked before the request even left the machine? Once you suspect a client-side policy, "try the same URL with a more permissive client (like Safari)" is often the fastest way to qualify it.