What exactly does App Transport Security block, which Info.plist keys unlock what, and what do you see when it blocks a request?

14.7.2 · L3 · Interview

Predict first

Which Info.plist keys unlock which URLs in a simulator app? (needs a booted iPhone simulator)

import Foundation

func sh(_ args: [String], input: String? = nil) -> (Int32, String) {
    let p = Process(); p.executableURL = URL(fileURLWithPath: "/usr/bin/env"); p.arguments = args
    let out = Pipe(); p.standardOutput = out; p.standardError = Pipe()
    try! p.run()
    let data = out.fileHandleForReading.readDataToEndOfFile()
    p.waitUntilExit()
    return (p.terminationStatus, String(decoding: data, as: UTF8.self))
}
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("ats\(getpid())")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let app = dir.appendingPathComponent("T.app")
try FileManager.default.createDirectory(at: app, withIntermediateDirectories: true)
let src = dir.appendingPathComponent("t.swift")
try """
import Foundation
for s in CommandLine.arguments.dropFirst() {
    var r = URLRequest(url: URL(string: s)!); r.timeoutInterval = 5
    let sem = DispatchSemaphore(value: 0)
    URLSession.shared.dataTask(with: r) { _, _, e in print(s, (e as NSError?)?.code ?? 0); sem.signal() }.resume()
    sem.wait()
}
""".write(to: src, atomically: true, encoding: .utf8)
let sdk = sh(["xcrun", "--sdk", "iphonesimulator", "--show-sdk-path"]).1.trimmingCharacters(in: .whitespacesAndNewlines)
let build = sh(["swiftc", "-sdk", sdk, "-target", "arm64-apple-ios17.0-simulator", "-swift-version", "5", src.path, "-o", app.appendingPathComponent("T").path])
print("built:", build.0 == 0)
let booted = sh(["xcrun", "simctl", "list", "devices", "booted"]).1
let device = booted.range(of: "[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}", options: .regularExpression).map { String(booted[$0]) }!
let base: [String: Any] = ["CFBundleExecutable": "T", "CFBundleIdentifier": "test.ats14", "CFBundleName": "T", "CFBundlePackageType": "APPL", "CFBundleVersion": "1", "CFBundleShortVersionString": "1", "MinimumOSVersion": "17.0", "CFBundleSupportedPlatforms": ["iPhoneSimulator"], "UIDeviceFamily": [1]]
let cases: [(String, [String: Any])] = [
    ("no ATS keys", [:]),
    ("allow arbitrary loads", ["NSAllowsArbitraryLoads": true]),
    ("exception example.invalid", ["NSExceptionDomains": ["example.invalid": ["NSExceptionAllowsInsecureHTTPLoads": true]]]),
    ("exception + subdomains", ["NSExceptionDomains": ["example.invalid": ["NSExceptionAllowsInsecureHTTPLoads": true, "NSIncludesSubdomains": true]]]),
    ("local networking only", ["NSAllowsLocalNetworking": true]),
]
print("codes: -1022 blocked by ATS, -1003 passed ATS (no such host), -1004 passed ATS (connection refused)")
for (name, ats) in cases {
    var plist = base
    if !ats.isEmpty { plist["NSAppTransportSecurity"] = ats }
    try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0).write(to: app.appendingPathComponent("Info.plist"))
    _ = sh(["codesign", "-f", "-s", "-", app.path])
    _ = sh(["xcrun", "simctl", "uninstall", device, "test.ats14"])
    _ = sh(["xcrun", "simctl", "install", device, app.path])
    let out = sh(["xcrun", "simctl", "launch", "--console-pty", "--terminate-running-process", device, "test.ats14",
                  "http://example.invalid/", "http://www.example.invalid/", "http://127.0.0.1:1/"]).1
    let codes = out.components(separatedBy: .newlines).filter { $0.hasPrefix("http") }.map { $0.split(separator: " ").last! }
    print(name + ":", codes.joined(separator: " "))
}
_ = sh(["xcrun", "simctl", "uninstall", device, "test.ats14"])
try? FileManager.default.removeItem(at: dir)
  • built: true / no ATS keys: -1022 -1022 -1004 / allow arbitrary loads: -1003 -1003 -1004 / exception example.invalid: -1003 -1022 -1004 / exception + subdomains: -1003 -1003 -1004 / local networking only: -1022 -1022 -1004
  • no ATS keys: -1003 -1003 -1004 / exception example.invalid: -1003 -1003 -1004
  • allow arbitrary loads: -1022 -1022 -1022

Mental model. ATS is a bouncer who checks the door policy before the guest even leaves home: the refusal comes instantly, and an exception on the list names exactly one address, not the whole street.

ATS applies to app bundles: a bare command-line tool run in the simulator fetched plain http://example.com successfully, while the same code packaged as an .app with an Info.plist was blocked, so test ATS inside a real app. A blocked request fails fast, before any network traffic, with NSURLErrorDomain code −1022 (appTransportSecurityRequiresSecureConnection). That makes ATS easy to separate from other failures in a test: a host that does not exist gives −1003 and a refused connection gives −1004, both of which mean ATS let the request through. I ran five Info.plist variants in an iOS 27.2 simulator app against three URLs and the results are in the program: with no keys, http://example.invalid, http://www.example.invalid are blocked and http://127.0.0.1:1 is not; NSAllowsArbitraryLoads unlocks all of them; an exception for example.invalid unlocks that host but not its subdomain until you add NSIncludesSubdomains; and NSAllowsLocalNetworking alone unlocks local addresses only, not public host names.

Observed details worth knowing: loopback (127.0.0.1) was never blocked, while in another run a public IP literal (192.0.2.1) was blocked despite documentation suggesting IP addresses are exempt, so do not rely on IP literals for plain HTTP; prefer a narrow, domain-scoped exception. Also NSAllowsArbitraryLoadsInWebContent affects only WKWebView content, not your URLSession calls (I did not load web content to test it). Review treats broad exceptions with suspicion and may ask for a justification (Q484); the right fixes are to serve the endpoint over TLS 1.2 or later with forward secrecy, or to scope an exception to the one legacy host. Not run: the TLS version and cipher requirements, and the NSExceptionRequiresForwardSecrecy style keys, which need a configurable TLS server.

ATS blocks first (−1022); exceptions are per host and per subdomain; local networking does not unlock public names.

Common traps

  • Testing ATS from a command-line tool or playground. Not enforced there; use an app bundle.
  • Expecting an exception for a domain to cover its subdomains. It needs NSIncludesSubdomains.
  • Turning on NSAllowsArbitraryLoads to fix one staging URL. Scope an exception to that host.

They might follow up with

  • How do you tell ATS from a network failure? −1022 is policy; −1003 and −1004 mean the request got past it.
  • Does ATS check the certificate? The certificate check is TLS trust evaluation; ATS is the policy requiring a secure connection.

Related questions

  • 14.7.1 How do you compute a public-key (SPKI) pin, how does the system evaluate a trust object with a custom anchor…
  • 12.2.2 How do TLS and App Transport Security protect a request, and what is certificate pinning, and why is it…
  • 25.3.2 How does App Review work, what causes common rejections, and what are export compliance, the privacy labels…

← How do you compute a public-key (SPKI) pin, how does the system…   What does a code signature actually protect, what does it not say… →