
Start here. App Transport Security (ATS) decisions are read from the running app's Info.plist, not toggled at runtime, so the only honest way to prove which exception keys permit which loads is to build one scheme per configuration, drive a URLSession request from an XCTest, and record whether the response was an HTTP status, an ATS block (NSURLErrorDomain code -1022), or a different network error. This article gives you a compact XCTest, a fill-in worksheet you can paste into a review packet, and a plan pick for the Figment Mac that fits the job.
A Figment virtual Intel macOS instance at $29/month (2 vCPU, 8 GiB RAM, 80 GiB disk; browser-console access, no inbound SSH) is a reasonable host when your project builds with Xcode 26 or earlier and uses a Simulator runtime that still supports Intel. If you need Xcode 27, visionOS, ARM-only runtimes, or several Simulator devices in parallel, pick the Figment M-series Mac from $95/month. Exact chip, RAM, disk and macOS version are set in the quote; physical plans collect requests until supply is confirmed.
The authoritative list of keys lives on Apple's NSAppTransportSecurity property-list reference. Your truth table should have one row per key you actually set, plus a control row with no exceptions at all.
| Key | Scope | Effect worth asserting |
|---|---|---|
NSAllowsArbitraryLoads | Global | Disables ATS for everything that does not have a per-domain entry |
NSAllowsArbitraryLoadsInMedia | Global, AVFoundation only | HTTP media loads via AVFoundation; does not affect URLSession |
NSAllowsArbitraryLoadsInWebContent | Global, WKWebView only | HTTP in web views; does not affect URLSession |
NSAllowsLocalNetworking | Global | Permits unqualified hostnames and .local without a per-domain block |
NSExceptionDomains | Container | Dictionary whose keys are domain strings |
NSExceptionAllowsInsecureHTTPLoads | Per-domain | Permits HTTP for the listed domain |
NSIncludesSubdomains | Per-domain | Extends the exception to subdomains of the listed domain |
NSExceptionMinimumTLSVersion | Per-domain | Lowers the minimum TLS version for the listed domain |
NSExceptionRequiresForwardSecrecy | Per-domain | Allows cipher suites without forward secrecy |
NSRequiresCertificateTransparency | Per-domain | Requires CT logs for the listed domain |
The two keys that cost the most review time are NSAllowsArbitraryLoadsInWebContent (teams expect it to cover URLSession; it does not) and per-domain entries that omit NSIncludesSubdomains and therefore silently miss API subdomains.
The test host must reach two endpoints whose reachability is controlled by you, otherwise an ATS block becomes indistinguishable from DNS failure, a captive portal, or a certificate error. Two options work well on an iOS Simulator running on a Mac:
NSAllowsLocalNetworking (or a per-domain exception for localhost) when the scheme you are proving needs the local row to succeed.NSIncludesSubdomains.If you use a local HTTPS listener with a self-signed certificate, install the root into the booted Simulator with xcrun simctl keychain <device> add-root-cert <cert.pem> so a URLSession failure signals ATS rather than certificate-trust rejection. Record the Simulator runtime version you used; ATS behaviour for local networking has changed across major OS versions and your evidence should pin the row to a runtime.
The example below is written for Xcode 16.4's XCTest and is not an executed benchmark. Replace the placeholders with endpoints your app actually hits, make sure the containing scheme's test-host Info.plist has the ATS keys you want to prove, and run the scheme locally before you rely on the asserted outcomes. The async pattern follows Apple's XCTestExpectation reference.
// ATSTruthTableTests.swift
import XCTest
final class ATSTruthTableTests: XCTestCase {
struct ATSRow: Encodable {
let scheme: String
let label: String
let url: String
let expected: String // "allow" or "block"
let observed: String // "allow", "ats-block", or "other-error"
let httpStatus: Int?
let errorDomain: String?
let errorCode: Int?
}
// Supply these from the scheme's environment variables.
// Each scheme supplies its own matrix matching its Info.plist keys.
private func matrix() -> [(label: String, url: String, expectAllow: Bool)] {
let env = ProcessInfo.processInfo.environment
let httpHost = env["ATS_HTTP_HOST"] ?? "localhost:8080"
let httpsHost = env["ATS_HTTPS_HOST"] ?? "localhost:8443"
return [
("control-https", "https://\(httpsHost)/ping", true),
("control-http", "http://\(httpHost)/ping", false),
]
}
func testATSTruthTable() throws {
let schemeName = ProcessInfo.processInfo.environment["ATS_SCHEME"] ?? "ATSDefault"
let session = URLSession(configuration: .ephemeral)
var rows: [ATSRow] = []
for tc in matrix() {
guard let url = URL(string: tc.url) else {
XCTFail("Invalid URL: \(tc.url)"); continue
}
let done = XCTestExpectation(description: "load \(tc.label)")
var observed = "other-error"
var status: Int? = nil
var errDomain: String? = nil
var errCode: Int? = nil
let task = session.dataTask(with: url) { _, response, error in
defer { done.fulfill() }
if let http = response as? HTTPURLResponse {
observed = "allow"
status = http.statusCode
} else if let e = error as NSError? {
errDomain = e.domain
errCode = e.code
if e.domain == NSURLErrorDomain && e.code == -1022 {
observed = "ats-block"
}
}
}
task.resume()
wait(for: [done], timeout: 15)
rows.append(ATSRow(
scheme: schemeName, label: tc.label, url: tc.url,
expected: tc.expectAllow ? "allow" : "block",
observed: observed, httpStatus: status,
errorDomain: errDomain, errorCode: errCode
))
// Only assert ATS behaviour. An "other-error" row is logged but
// neither passes nor fails: fix the endpoint and re-run.
if observed != "other-error" {
XCTAssertEqual(
observed == "allow", tc.expectAllow,
"ATS mismatch [\(tc.label)]: expected \(tc.expectAllow ? "allow" : "block"), observed \(observed)"
)
}
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(rows)
let attachment = XCTAttachment(
uniformTypeIdentifier: "public.json",
name: "ats-truth-table-\(schemeName).json",
payload: data
)
attachment.lifetime = .keepAlways
add(attachment)
}
}
Points worth reading twice:
other-error branch means the row is not usable evidence. A DNS failure, a TLS trust failure, or a timeout all land there. Fix the endpoint before you claim ATS proved anything.-1022 in NSURLErrorDomain counts as an ATS block. Other codes (for example -1200 secure-connection-failed) can be a trust-store or TLS-negotiation problem rather than an ATS decision.ATS_SCHEME variable keeps the attachment filenames distinct so xcrun xcresulttool merge output is easy to diff.ATS keys live in the app's Info.plist, so cover the key matrix by building one scheme per configuration and reusing the same test class. For each scheme, run the test once on your target Simulator runtime and paste the filled worksheet into the review packet.
| Scheme | Test-host Info.plist keys | Row to prove |
|---|---|---|
ATSDefault | none | control-http → ats-block ; control-https → allow |
ATSArbitraryLoads | NSAllowsArbitraryLoads = true | control-http → allow |
ATSPerDomain | NSExceptionDomains[your.host].NSExceptionAllowsInsecureHTTPLoads = true | http://your.host → allow ; http://other.host → ats-block |
ATSSubdomains | Above plus NSIncludesSubdomains = true | http://api.your.host → allow |
ATSWebContentOnly | NSAllowsArbitraryLoadsInWebContent = true | URLSession HTTP → still ats-block |
ATSTLSFloor | NSExceptionMinimumTLSVersion = TLSv1.2 for your.host | TLS 1.1 endpoint → ats-block ; TLS 1.2 → allow |
Worksheet rows (fill per run, attach beside the xcresult):
xcodebuild -version): _______ATS_HTTP_HOST / ATS_HTTPS_HOST: _______Run each scheme in CI, merge the attached JSON with xcrun xcresulttool merge, and commit the merged truth table alongside your review notes. The combination of the merged JSON and a signed worksheet is the artifact reviewers can actually diff between releases.
Per Apple's porting-to-Apple-silicon guide, running Xcode on an Intel processor requires Xcode 26 or earlier; Xcode 27 itself is Apple silicon only. Not every current Simulator runtime still supports Intel — check the Xcode release notes for the exact pair before you commit to the VM.
The Figment virtual Intel macOS instance at $29/month is a real macOS environment reached through a browser console. It is enough for a single Simulator and the matrix above if your toolchain fits Xcode 26 or earlier. There is no inbound SSH to the VM; your tests either run in the desktop session or your CI runner connects outbound to the service you already use.
For Xcode 27, visionOS, parallel Simulator devices, or ARM-only dependencies, the Figment M-series Mac starts at $95/month and the exact configuration (chip, RAM, disk, macOS version) is confirmed in the quote. Physical plans collect requests until supply is confirmed, so state your Xcode version, required Simulator runtimes, and parallelism up front.
Run ATS verification on a Figment Mac or request a configuration quote and describe the test matrix you plan to run so the plan matches the job.
Related testing guides: