iOS · A Field Manual
The iOS Networking, Security & Platform Tooling Field Manual
A deep, hands-on tutorial covering TCP/IP, TLS, iOS Security APIs, the Network Extension framework, MDM, and the Instruments & Power Profiler toolchain.
This document is intentionally long. It is structured so you can read it cover-to-cover or jump to a part. Every concept is followed by code or a concrete iOS example. The goal is not just "what" but "why" and "how it actually works under the hood."
Part 1 — Networking Fundamentals: TCP/IP from the Wire Up
You cannot reason about iOS networking — why a request is slow, why a socket stalls, why a connection drops on cellular — without a solid mental model of the protocol stack. This part builds that model and then ties it back to what URLSession and Network.framework actually do.
1.1 The Protocol Stack
You'll see two models referenced everywhere: the seven-layer OSI model and the four-layer TCP/IP model. OSI is pedagogically tidy. TCP/IP is what actually ships data.
| TCP/IP Layer | OSI Layers | What lives here | iOS-side touchpoint |
|---|---|---|---|
| Application | Application, Presentation, Session | HTTP, HTTP/2, HTTP/3, WebSocket, gRPC, DNS | URLSession, URLSessionWebSocketTask, Network.framework |
| Transport | Transport | TCP, UDP, QUIC | NWConnection, raw sockets via NWProtocolTCP / NWProtocolUDP |
| Internet | Network | IPv4, IPv6, ICMP | Routing happens at the OS level; surfaced via NWPath, nw_path_monitor |
| Link | Data Link, Physical | Ethernet, Wi-Fi (802.11), Cellular (LTE/5G NR) | CTTelephonyNetworkInfo, NWInterface.InterfaceType |
Encapsulation is the key mechanic. When your app sends a POST request, the bytes flow downward: HTTP headers + body → wrapped in a TCP segment → wrapped in an IP packet → wrapped in a Wi-Fi frame. Each layer adds its own header. On the receiving side, headers are peeled off in reverse.
A useful number to memorize: on Ethernet/Wi-Fi the default MTU (Maximum Transmission Unit) is 1500 bytes. TCP and IP headers consume 20 bytes each (40 for IPv6), so the typical MSS (Maximum Segment Size) — the payload a single TCP segment can carry — is around 1460 bytes (or 1440 with timestamps). If you send a 100 KB request body, it's roughly 70 segments on the wire. This matters when you measure latency.
1.2 The Internet Layer: IP
IP is connectionless and unreliable. It will best-effort deliver a packet from a source address to a destination address. It can drop it, duplicate it, or deliver it out of order. Everything reliable (TCP, QUIC) is built on top of this.
IPv4 vs IPv6
IPv4 addresses are 32 bits (203.0.113.42), IPv6 are 128 bits (2001:db8::1). iOS has been IPv6-only-capable since iOS 9 — App Review explicitly tests apps on an IPv6-only network. The practical rule: never hardcode IPv4 literals or assume IPv4 sockets in your code. Use hostnames, and let getaddrinfo (which URLSession uses under the hood) return whatever family the network provides.
NAT and what it does to your sockets
Most iOS devices sit behind NAT (Network Address Translation). Your phone has a private RFC1918 address like 192.168.1.42; the router rewrites the source address and port to its public IP. The router keeps a mapping table. When a NAT entry idles out (often 30–120 seconds for UDP, 5+ minutes for TCP), an inbound packet to your phone has nowhere to go. This is why long-lived idle connections die on cellular and why push notifications use the long-lived APNs socket the OS manages for you.
If you're building anything with persistent connections (WebSocket, MQTT, gRPC streaming), you need application-level keepalives more aggressive than the NAT timeout. A 25–30 second ping is a common choice.
1.3 The Transport Layer: TCP
TCP turns the unreliable IP fabric into an in-order, reliable, byte-stream abstraction. It does this with sequence numbers, acknowledgments, retransmissions, and congestion control.
The Three-Way Handshake
Before any HTTP byte flows, TCP opens the connection:
Client Server
| ---- SYN, seq=x -----------------> |
| <--- SYN+ACK, seq=y, ack=x+1 ----- |
| ---- ACK, ack=y+1 -----------------> |
| |
| (now you can send data) |
That's one round trip (1-RTT) of pure overhead before you've sent a single useful byte. On a cellular link with 80 ms RTT, opening a connection costs 80 ms. Add TLS on top and it's another 1-RTT (TLS 1.3) or 2-RTT (TLS 1.2) before HTTP starts. This is why connection reuse matters and why HTTP/3 (which folds TLS into a single QUIC handshake over UDP) is faster on lossy networks.
The Four-Way Close
Closing is also negotiated:
Client Server
| ---- FIN ----------------------> |
| <--- ACK ----------------------- |
| <--- FIN ----------------------- |
| ---- ACK ----------------------> |
| |
| (TIME_WAIT for 2*MSL on initiator) |
The TIME_WAIT state on the initiator lasts roughly 60 seconds on most stacks. It exists to absorb stragglers from the closed connection so they don't bleed into a new connection that reuses the same 4-tuple. On servers under load this matters; on iOS clients it's mostly invisible but worth knowing.
Flow Control
Each side advertises a receive window (rwnd) — how many bytes it's willing to buffer. The sender can never have more unacknowledged bytes in flight than the receiver's advertised window. This prevents a fast sender from drowning a slow receiver.
Congestion Control
This is the smart part. The sender also maintains a congestion window (cwnd) that estimates how much the network path can take. It starts small (the "initial window" — modern stacks use ~10 segments), grows on every ACK (slow start, exponential), then switches to linear growth (congestion avoidance) once it crosses a threshold. On loss, it backs off.
The algorithm matters. Linux defaults to CUBIC. Apple platforms have used several over the years and now default to (or can use) BBR in many configurations, which models bandwidth and RTT explicitly rather than reacting to loss. The practical implication: on lossy Wi-Fi, BBR holds throughput better than CUBIC. You don't tune this on iOS, but you should understand that the "slowness" you see early in a connection (slow start) is by design.
Nagle and Delayed ACKs — the classic latency trap
Two TCP features can interact badly:
- Nagle's algorithm: hold small writes until either there's a full MSS to send or all outstanding data is ACKed. Designed to avoid telnet-era tinygrams.
- Delayed ACKs: the receiver holds an ACK for up to ~40 ms hoping to piggyback it on response data.
If your app does a small write and waits for a response, Nagle on the sender + delayed ACK on the peer can introduce 40+ ms of latency for no reason. URLSession handles this for you. If you ever drop down to Network.framework or BSD sockets, set TCP_NODELAY for latency-sensitive small writes.
Connection state and NWPath
iOS surfaces network state through NWPathMonitor. You should be using this everywhere a long-lived socket lives:
import Network
final class NetworkPathObserver {
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "network.path.monitor")
var onChange: ((NWPath) -> Void)?
func start() {
monitor.pathUpdateHandler = { [weak self] path in
// path.status: .satisfied, .unsatisfied, .requiresConnection
// path.isExpensive: cellular or personal hotspot
// path.isConstrained: Low Data Mode
// path.usesInterfaceType(.cellular / .wifi / .wiredEthernet)
self?.onChange?(path)
}
monitor.start(queue: queue)
}
func stop() { monitor.cancel() }
}
Three properties are easy to underuse:
isExpensive— true on cellular and personal hotspot. Defer large downloads.isConstrained— Low Data Mode is on. Skip prefetching, video preloads, etc.unsatisfiedReason(iOS 14+) — tells you why the path is unsatisfied:.cellularDenied,.wifiDenied,.localNetworkDenied,.notAvailable.
If you set URLRequest.allowsConstrainedNetworkAccess = false and URLRequest.allowsExpensiveNetworkAccess = false on prefetch requests, the system enforces the policy for you without you having to inspect the path manually.
1.4 UDP and Why It's Coming Back
UDP is connectionless: send a datagram, hope it arrives. No handshake, no retransmission, no ordering, no congestion control. For years UDP was relegated to DNS and real-time media (RTP, voice, games).
QUIC changed that. QUIC is a transport protocol that runs over UDP and implements its own connection, reliability, congestion control, and (mandatory) TLS 1.3 encryption. HTTP/3 is HTTP over QUIC. Apple has shipped QUIC client support in URLSession since iOS 15 via URLSessionConfiguration.allowsConstrainedNetworkAccess-adjacent infrastructure — practically, you get HTTP/3 automatically when the server advertises it via Alt-Svc and the system has negotiated it.
Two QUIC wins worth internalizing:
- 0-RTT and 1-RTT handshake — QUIC fuses the TCP + TLS handshake into one round trip (or zero, on resumption with 0-RTT data).
- No head-of-line blocking across streams — in HTTP/2 over TCP, a lost packet on one stream stalls all streams sharing the connection (because TCP guarantees in-order delivery). In QUIC, each stream is independently ordered.
1.5 DNS — the resolver you don't see
DNS turns api.example.com into an IP. It's UDP-based by default (with TCP fallback for large responses), and it's a frequent source of mysterious latency. iOS caches DNS results aggressively at the OS level; you generally cannot and should not try to manage this from your app.
Two modern wrinkles:
- DNS over HTTPS (DoH) and DNS over TLS (DoT) — encrypted DNS. iOS 14+ supports these as system-wide settings configured by an MDM profile or by a
NEDNSSettingsManagerconfiguration. Your app can ship a DNS settings provider extension to force encrypted DNS for the whole device (more on this in Part 4). - Happy Eyeballs (RFC 8305) — when a name resolves to both IPv4 and IPv6, iOS tries them in parallel and uses whichever connects first. This is why you should never assume which address family you'll get.
1.6 HTTP/1.1, HTTP/2, HTTP/3 — what URLSession actually does
URLSession will, by default, use the highest version both ends support. You can inspect what it picked via URLSessionTaskMetrics:
final class MetricsCollector: NSObject, URLSessionTaskDelegate {
func urlSession(_ session: URLSession,
task: URLSessionTask,
didFinishCollecting metrics: URLSessionTaskMetrics) {
for tx in metrics.transactionMetrics {
let proto = tx.networkProtocolName ?? "unknown" // "http/1.1", "h2", "h3"
let reused = tx.isReusedConnection
let dns = interval(tx.domainLookupStartDate, tx.domainLookupEndDate)
let connect = interval(tx.connectStartDate, tx.connectEndDate)
let tls = interval(tx.secureConnectionStartDate, tx.secureConnectionEndDate)
let ttfb = interval(tx.requestEndDate, tx.responseStartDate)
print("""
\(tx.request.url?.absoluteString ?? "-")
proto=\(proto) reused=\(reused)
dns=\(dns)ms connect=\(connect)ms tls=\(tls)ms ttfb=\(ttfb)ms
""")
}
}
private func interval(_ start: Date?, _ end: Date?) -> String {
guard let s = start, let e = end else { return "-" }
return String(format: "%.1f", e.timeIntervalSince(s) * 1000)
}
}
This is the single most useful diagnostic you can wire into your network layer. Ship it behind a debug flag in every app.
HTTP/2 multiplexing
HTTP/1.1 over a single TCP connection is one-request-at-a-time. To parallelize, the browser/client opens N connections (HTTP/1.1 conventionally allows 6 to one origin). Each connection costs a TCP+TLS handshake.
HTTP/2 runs multiple streams over a single TCP+TLS connection. URLSession uses one HTTP/2 connection per origin by default, and that's almost always what you want. The classic anti-pattern is setting httpMaximumConnectionsPerHost to a large number — for HTTP/2 servers it just gives you redundant connections and breaks priority and flow control. Leave it alone unless you have a very specific HTTP/1.1 origin behaving badly.
HTTP/3
You get this for free when the server supports it. From your app's perspective the API is unchanged. The wins show up under packet loss and on networks where the TCP handshake is slow.
1.7 Practical: a robust URLSession configuration
Here's a configuration that captures most of what we've discussed and a few extra defensive choices:
import Foundation
enum NetworkClientFactory {
static func make() -> URLSession {
let config = URLSessionConfiguration.default
// Connection-level
config.waitsForConnectivity = true // queue, don't immediately fail offline
config.timeoutIntervalForRequest = 30 // per-request idle timeout
config.timeoutIntervalForResource = 120 // overall resource cap
config.httpMaximumConnectionsPerHost = 6 // sane default, leave alone
config.requestCachePolicy = .useProtocolCachePolicy
// Quality of network
config.allowsExpensiveNetworkAccess = true // override per-request for prefetch
config.allowsConstrainedNetworkAccess = true // override per-request for prefetch
config.allowsCellularAccess = true
// TLS minimum — set per-request via URLSessionDelegate, or globally:
config.tlsMinimumSupportedProtocolVersion = .TLSv12
// .TLSv13 if your backend supports it everywhere
// HTTP additional headers — only headers that apply to EVERY request
config.httpAdditionalHeaders = [
"Accept-Encoding": "br, gzip"
]
let delegate = TLSPinningDelegate() // see Part 2
return URLSession(configuration: config,
delegate: delegate,
delegateQueue: nil)
}
}
A few things to internalize:
waitsForConnectivityis the single most underrated knob. It changes the request from "fail immediately on no network" to "wait until the OS thinks there's a viable path, then go." Combined withtimeoutIntervalForResource, it makes your app behave gracefully when the user is in a tunnel.timeoutIntervalForRequestis an idle timeout — it resets every time bytes flow. A 30-second value does not mean "the request will finish in 30 seconds."timeoutIntervalForResourceis the wall-clock cap. Use it.- Never put auth headers into
httpAdditionalHeaders. They leak to every host the session talks to.
1.8 Network.framework — when URLSession isn't enough
For raw sockets, custom protocols, peer-to-peer (Bonjour), or building your own client (think: an MQTT client, a SOCKS proxy, an MMORPG protocol), reach for Network.framework. It's a modern, Swift-friendly replacement for BSD sockets, with first-class TLS, proxy, and path-aware APIs.
A minimal TCP client with TLS:
import Network
let host = NWEndpoint.Host("api.example.com")
let port = NWEndpoint.Port(integerLiteral: 443)
let tls = NWProtocolTLS.Options() // default TLS, system trust
let tcp = NWProtocolTCP.Options()
tcp.noDelay = true // disable Nagle for low-latency small writes
tcp.connectionTimeout = 10
let params = NWParameters(tls: tls, tcp: tcp)
let conn = NWConnection(host: host, port: port, using: params)
conn.stateUpdateHandler = { state in
switch state {
case .ready: print("connected, can send")
case .waiting(let e): print("waiting: \(e)")
case .failed(let e): print("failed: \(e)")
case .cancelled: print("cancelled")
default: break
}
}
conn.start(queue: .global(qos: .userInitiated))
// Send some bytes:
conn.send(content: "GET / HTTP/1.1\r\nHost: api.example.com\r\n\r\n".data(using: .utf8),
completion: .contentProcessed { error in
if let error { print("send error: \(error)") }
})
// Receive:
func receive() {
conn.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let data, !data.isEmpty {
print("got \(data.count) bytes")
}
if isComplete { conn.cancel() } else { receive() }
}
}
receive()
This is the substrate underneath a lot of the Network Extension framework (Part 4). It's also extremely useful for building custom protocol clients that don't fit into the HTTP-shaped world.
1.9 What to remember from Part 1
- IP is unreliable; TCP and QUIC make it reliable. Every HTTP request you make pays at least 1 RTT for TCP, often another 1–2 for TLS. Reuse connections.
- Cellular MTU and NAT timeouts shape your long-lived socket behavior. Keepalive at ~25 s.
NWPathMonitor,isExpensive, andisConstrainedare first-class signals — use them.- Capture
URLSessionTaskMetricseverywhere. It's the cheapest observability you can buy. URLSessiondoes the right thing 95% of the time.Network.frameworkis the right tool for the other 5%.
Part 2 — TLS: Trust, Handshakes, and Certificate Pinning
TLS is the layer that makes HTTPS, and it is the layer most iOS developers know least well. Let's fix that.
2.1 What TLS Actually Does
TLS provides three guarantees, in this priority order:
- Authentication — you are talking to the entity named in the certificate (server auth always; client auth optionally).
- Confidentiality — nobody in the middle can read the payload.
- Integrity — nobody in the middle can modify the payload without detection.
Notice the order: authentication first. Confidentiality without authentication is encryption to an unknown party — which means encryption to potentially an attacker. This is the entire reason "self-signed" production endpoints are dangerous: you've turned off the part that matters most.
2.2 Versions: What's Alive
- SSL 2.0 / 3.0 — dead. Don't even configure these as fallbacks.
- TLS 1.0 / 1.1 — deprecated. Apple removed them from
URLSessiondefaults; you'd have to actively re-enable them. Don't. - TLS 1.2 — the workhorse. Universally supported. Two-round-trip handshake.
- TLS 1.3 — current standard (RFC 8446, 2018). One-round-trip handshake, mandatory PFS, leaner cipher list, encrypted handshake messages.
On iOS, set tlsMinimumSupportedProtocolVersion = .TLSv12 at minimum, and .TLSv13 if your backend everywhere supports it. App Transport Security (ATS) — Apple's policy — already requires TLS 1.2 unless you've added an exception.
2.3 The TLS 1.2 Handshake, in Detail
Client Server
| |
| --- ClientHello ---------------------------> |
| (versions, cipher suites, random, |
| SNI, ALPN, key_share if 1.3) |
| |
| <--- ServerHello -------------------------- |
| (chosen version, cipher, random, |
| chosen ALPN) |
| <--- Certificate (chain) ------------------ |
| <--- ServerKeyExchange (for ECDHE) -------- |
| <--- ServerHelloDone ---------------------- |
| |
| [verify chain, build pre-master] |
| |
| --- ClientKeyExchange (pubkey) ------------> |
| --- ChangeCipherSpec ---------------------> |
| --- Finished (encrypted) -----------------> |
| |
| <--- ChangeCipherSpec --------------------- |
| <--- Finished (encrypted) ----------------- |
| |
| (now application data flows, encrypted) |
That's 2 RTT before HTTP can begin, on top of the 1 RTT for TCP. On an 80 ms-RTT cellular link that's 240 ms gone before you even sent a request.
What ClientHello carries
- SNI (Server Name Indication) — the hostname you're connecting to, sent in clear text. This lets a single IP host multiple TLS sites. It also leaks which host you're talking to. Encrypted Client Hello (ECH) is the fix; iOS 17+ supports it experimentally.
- ALPN (Application-Layer Protocol Negotiation) — the protocols you'd like to use.
h2,http/1.1,h3(over QUIC). The server picks one. This is how HTTP/2 is negotiated. - Cipher suites — an ordered list of cryptographic combinations you support.
- Supported groups — elliptic curves and DH groups you can use for key exchange.
- Signature algorithms — what cert signatures you can verify.
What's in a cipher suite
A TLS 1.2 cipher suite looks like:
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
Read it as four parts:
| Part | Meaning |
|---|---|
ECDHE |
Key exchange: Elliptic Curve Diffie–Hellman Ephemeral. "Ephemeral" gives you Perfect Forward Secrecy |
RSA |
Server authentication: certificate is signed with an RSA key |
AES_128_GCM |
Bulk cipher: AES with 128-bit key in Galois/Counter Mode (AEAD) |
SHA256 |
PRF / MAC hash (in AEAD modes this is the PRF only — the AEAD itself provides integrity) |
TLS 1.3 simplifies this dramatically. Suites are just TLS_AES_128_GCM_SHA256, TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256. Key exchange and signatures are negotiated separately.
Perfect Forward Secrecy
PFS means: if an attacker records all your TLS traffic today and later steals the server's private key, they still can't decrypt the recorded sessions. This is because the actual session key was derived from an ephemeral key exchange (the E in ECDHE) that nobody saved. TLS 1.3 mandates PFS. TLS 1.2 doesn't, but you should not configure any non-PFS suite. ATS enforces PFS for default-policy connections.
2.4 The TLS 1.3 Handshake
TLS 1.3 cuts a full round trip:
Client Server
| |
| --- ClientHello + key_share ----------------> |
| |
| <--- ServerHello + key_share ---------------- |
| {EncryptedExtensions} |
| {Certificate} |
| {CertificateVerify} |
| {Finished} |
| |
| --- {Finished} -----------------------------> |
| --- Application Data -----------------------> |
Everything inside {} is encrypted with handshake keys derived after the first round trip. The certificate is now encrypted — passive observers can no longer see it. The client also gets to send application data with its Finished message.
TLS 1.3 also supports 0-RTT ("early data"): on resumption, the client can send a request along with the first handshake message. This is fast but has a replay-attack caveat — early data is replayable, so it must only be used for idempotent requests. URLSession handles this conservatively; you don't directly toggle it.
2.5 Certificates and the Trust Chain
A server's certificate is an X.509 document binding:
- A public key
- A subject (CN, SAN list — the domain names this cert is good for)
- An issuer
- A validity range
- Extensions (key usage, EKU, AIA, CRL distribution, OCSP responder URL, SCT for Certificate Transparency, etc.)
- A signature from the issuer's private key
The chain of trust is:
leaf → intermediate(s) → root
The root is in your device's trust store. iOS ships with Apple's curated root store; you can see it in Settings → General → About → Certificate Trust Settings. The intermediates may be served by the server during the handshake (in the Certificate message), or the client may have cached them, or the client may fetch them via AIA (Authority Information Access) — though many clients, iOS included, will not AIA-fetch and will fail if the server doesn't send the intermediates. Misconfigured intermediate chains are the #1 cause of "works on browser, fails on iOS" TLS bugs.
How iOS validates a chain
SecTrustEvaluateWithError (or its async cousin) checks:
- The leaf's name matches the requested host (CN/SAN, with wildcard rules).
- Every signature in the chain verifies up to a trusted root.
- Every cert is within its validity window.
- No cert in the chain is revoked (best-effort — see OCSP/CRL below).
- Certificate Transparency (CT) requirements are met for certs issued after 2018 (must include at least 2 SCTs from logs Apple trusts).
- ATS policies are satisfied (TLS version, cipher PFS).
Revocation: OCSP, CRL, and OCSP Stapling
When a private key is compromised, the CA needs a way to tell clients "this cert is dead." Two mechanisms:
- CRL (Certificate Revocation List): the CA publishes a big signed list. Slow, large, awful.
- OCSP (Online Certificate Status Protocol): the client asks an OCSP responder if a specific cert is still good. Adds a round trip and leaks browsing data to the CA.
Neither is great. OCSP stapling fixes this: the server periodically fetches a signed OCSP response from its CA and attaches ("staples") it to the TLS handshake. The client gets the proof inline, no extra request. iOS supports OCSP stapling via the must-staple certificate extension.
There's also Apple's revocation infrastructure — Apple distributes its own revocation data via the system. This is one of the levers Apple holds.
2.6 Certificate Pinning
The problem pinning solves: the entire CA system is "trust on first sight." If any CA in the device's trust store issues a cert for your domain — willingly, by compromise, by government order — the device will trust it. Most enterprise MitM proxies and many state-level adversaries exploit exactly this.
Pinning narrows the trust set. Instead of trusting "any cert from any CA the device trusts," your app says: "I will only accept a cert whose public key (or full cert, or specific issuer) matches one of these values I shipped with the binary."
What to pin
You have three choices, in increasing fragility:
- Public key pinning (SPKI) — pin the SubjectPublicKeyInfo of the leaf or intermediate. The cert can be reissued with the same key, and pinning still works. This is the recommended approach.
- Certificate pinning — pin the full leaf or intermediate. If the cert is reissued (even with the same key), your app breaks.
- CA pinning — pin to a CA. Reduces, but doesn't eliminate, MitM risk; relies on the CA staying honest.
What to pin to
Pin to the leaf SPKI or to an intermediate SPKI, ideally both with a backup:
- Leaf-only: most secure, most fragile. If you rotate the leaf and forget to ship an app update, the app dies. Bad.
- Intermediate-only: more stable. Intermediate keys rotate every few years.
- Both, with a backup pin for the next planned intermediate: this is the durable pattern.
You ship multiple pins; the validator accepts a match against any of them. Best practice: ship the current intermediate, the next intermediate (pre-staged with your CA), and possibly the leaf. Rotate intermediates by shipping an app version that includes both old and new, waiting for adoption, then deprecating the old.
Implementation: pinning in URLSession
Here's a complete, production-shaped pinning delegate. It pins to a set of SPKI SHA-256 hashes (base64). It accepts the connection if any cert in the server-presented chain matches any configured pin for the host.
import Foundation
import CryptoKit
import Security
struct PinSet {
/// Map of host -> set of SPKI SHA-256 hashes (base64-encoded).
let pinsByHost: [String: Set<String>]
}
final class TLSPinningDelegate: NSObject, URLSessionDelegate {
private let pinSet: PinSet
init(pinSet: PinSet) {
self.pinSet = pinSet
super.init()
}
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition,
URLCredential?) -> Void) {
// Only handle server-trust challenges here.
guard challenge.protectionSpace.authenticationMethod
== NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
let host = challenge.protectionSpace.host
// 1. Standard trust evaluation FIRST. Pinning is an *additional* check,
// never a replacement for chain validation. (Many naive pinning
// implementations skip this and accept expired/revoked certs.)
var error: CFError?
let trusted = SecTrustEvaluateWithError(serverTrust, &error)
guard trusted else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// 2. Look up the pins for this host.
guard let expected = pinSet.pinsByHost[host], !expected.isEmpty else {
// No pins configured for this host — fall back to system trust.
completionHandler(.useCredential, URLCredential(trust: serverTrust))
return
}
// 3. Walk the chain and hash every SPKI. Match against expected.
let chainCount = SecTrustGetCertificateCount(serverTrust)
for i in 0..<chainCount {
guard let cert = SecTrustGetCertificateAtIndex(serverTrust, i),
let pubKey = SecCertificateCopyKey(cert),
let spkiHash = Self.spkiSha256Base64(of: pubKey) else { continue }
if expected.contains(spkiHash) {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
return
}
}
// 4. No pin matched. Reject.
completionHandler(.cancelAuthenticationChallenge, nil)
}
/// Computes SHA-256(SubjectPublicKeyInfo) base64-encoded.
/// SecKeyCopyExternalRepresentation returns just the key bits; we must
/// wrap them in the SPKI DER prefix to get the canonical SPKI hash that
/// matches `openssl x509 -pubkey | openssl pkey -pubin -outform DER | sha256sum`.
static func spkiSha256Base64(of key: SecKey) -> String? {
guard let attrs = SecKeyCopyAttributes(key) as? [String: Any],
let keyType = attrs[kSecAttrKeyType as String] as? String,
let keySize = attrs[kSecAttrKeySizeInBits as String] as? Int,
let keyData = SecKeyCopyExternalRepresentation(key, nil) as Data?
else { return nil }
// Prepend the correct SPKI DER header for the key's algorithm + size.
guard let header = Self.spkiHeader(keyType: keyType, keySizeInBits: keySize)
else { return nil }
var spki = Data()
spki.append(header)
spki.append(keyData)
let digest = SHA256.hash(data: spki)
return Data(digest).base64EncodedString()
}
/// Canonical SPKI DER prefixes. Shortened table — extend for any key type
/// you actually use.
private static func spkiHeader(keyType: String, keySizeInBits: Int) -> Data? {
switch (keyType, keySizeInBits) {
case (String(kSecAttrKeyTypeRSA), 2048):
return Data([0x30, 0x82, 0x01, 0x22, 0x30, 0x0d, 0x06, 0x09,
0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01,
0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0f, 0x00])
case (String(kSecAttrKeyTypeRSA), 4096):
return Data([0x30, 0x82, 0x02, 0x22, 0x30, 0x0d, 0x06, 0x09,
0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01,
0x01, 0x05, 0x00, 0x03, 0x82, 0x02, 0x0f, 0x00])
case (String(kSecAttrKeyTypeECSECPrimeRandom), 256):
return Data([0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86,
0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a,
0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03,
0x42, 0x00])
case (String(kSecAttrKeyTypeECSECPrimeRandom), 384):
return Data([0x30, 0x76, 0x30, 0x10, 0x06, 0x07, 0x2a, 0x86,
0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x05, 0x2b,
0x81, 0x04, 0x00, 0x22, 0x03, 0x62, 0x00])
default:
return nil
}
}
}
How to generate the pin values
From the leaf or intermediate cert (PEM):
openssl x509 -in cert.pem -pubkey -noout \
| openssl pkey -pubin -outform DER \
| openssl dgst -sha256 -binary \
| base64
The output is the value that goes in your PinSet.
Pinning with the failsafe escape hatch
A pinning implementation without a kill switch is a footgun. The pattern I'd ship:
- App contains pins for
api.example.com. - App contains an additional signed configuration channel — for example, fetch a config from a CDN at startup, signed with a key you ship in-binary. The config can disable pinning or replace the pin set.
- If the device clock looks wrong (off by more than a few hours), don't enforce pinning — clock drift will manifest as cert expiry and you'll lock users out.
Document a written runbook for "we need to disable pinning right now" before you ever ship pinning.
Network Security configuration via Info.plist
You can also pin via Info.plist's NSAppTransportSecurity keys, though the supported pinning fields here are limited compared to what Android exposes. ATS lets you control minimum TLS version, PFS requirement, cipher list, certificate transparency requirement, and per-domain exceptions:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSPinnedDomains</key>
<dict>
<key>api.example.com</key>
<dict>
<key>NSPinnedCAIdentities</key>
<array>
<dict>
<key>SPKI-SHA256-BASE64</key>
<string>YourBase64HashHere==</string>
</dict>
</array>
</dict>
</dict>
</dict>
NSPinnedDomains (iOS 14+) is Apple's built-in pinning. It does proper SPKI matching and integrates with system trust evaluation. If you're starting from scratch, prefer this — less code, less to get wrong, and the system handles edge cases (mixed chains, partial chains) for you. You only need the custom delegate approach if you need behavior NSPinnedDomains doesn't support (dynamic pins, telemetry on mismatches, soft-fail modes during rollout).
2.7 Client Authentication (mTLS)
Server auth proves the server is who it says it is. Mutual TLS adds the reverse: the client presents a certificate and the server verifies it. This is common in enterprise environments — VPN clients, certain B2B APIs, and almost all MDM-enrolled scenarios.
The challenge type in URLSessionDelegate is NSURLAuthenticationMethodClientCertificate. You respond with a URLCredential built from an identity:
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition,
URLCredential?) -> Void) {
if challenge.protectionSpace.authenticationMethod
== NSURLAuthenticationMethodClientCertificate {
// Load the identity from Keychain (it was installed via MDM profile
// or by your app via SecItemAdd with kSecClassIdentity).
let query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrLabel as String: "com.example.mtls.client",
kSecReturnRef as String: true
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let identity = item else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let cred = URLCredential(identity: identity as! SecIdentity,
certificates: nil,
persistence: .forSession)
completionHandler(.useCredential, cred)
return
}
// ... server trust handling above
}
In enterprise/MDM contexts, the identity arrives via a .p12 shipped through a configuration profile (Part 5), and you read it out of the keychain by label. In some setups your code never sees the private key — it lives in the Secure Enclave or is keychain-restricted to kSecAttrAccessibleWhenUnlockedThisDeviceOnly.
2.8 Practical Tooling for Debugging TLS
Two tools you should have on your dev machine:
openssl s_client -connect host:443 -servername host -alpn h2,http/1.1 -showcerts— shows the negotiated version, suite, ALPN, full chain, and whether OCSP stapling is in use.nscurl --ats-diagnostics --verbose https://api.example.com— runs every relevant ATS configuration against your endpoint and reports which ones pass. Indispensable for diagnosing ATS rejection.
For on-device inspection, you can use Charles or Proxyman with their CA installed and trusted on the device. Important nuance: once you implement certificate pinning, these tools stop working — which is the whole point. Have a debug build flag that disables pinning so you can keep debugging.
2.9 What to remember from Part 2
- TLS authenticates first, then encrypts. Authentication failures are not "warnings."
- TLS 1.2 is the floor; 1.3 saves a round trip and encrypts the cert.
- Chains break in production when intermediates aren't served. Test with
openssl s_client. - Pin to SPKI, ship multiple pins, have a kill switch, prefer Apple's
NSPinnedDomainsunless you need custom behavior. - mTLS is mostly a Keychain plumbing problem on the client side.
Part 3 — iOS Security APIs: Keychain, CryptoKit, Secure Enclave
This part covers the day-to-day APIs you reach for: Keychain Services for storing secrets, CryptoKit for everything cryptographic, the Secure Enclave for keys that should never see RAM, LocalAuthentication for gating access with biometrics, and a brief tour of App Attest / DeviceCheck for proving the request is coming from a real app on a real device.
3.1 Keychain Services
The Keychain is a system-managed encrypted database for small secrets: passwords, tokens, symmetric keys, identities. It's accessible only to processes that match the entitlements of the app that wrote the item, and it survives app updates, app deletes (depending on settings), and device backups (depending on settings).
Item classes
Each Keychain item belongs to a class:
| Class constant | Use case |
|---|---|
kSecClassGenericPassword |
App-specific tokens, API keys, arbitrary blobs |
kSecClassInternetPassword |
Credentials tied to a URL (host, port, protocol, path) |
kSecClassCertificate |
X.509 certs |
kSecClassKey |
Cryptographic keys (sym or asym) |
kSecClassIdentity |
Cert + matching private key (used in mTLS) |
For most app code, kSecClassGenericPassword is what you want.
Item attributes — the primary key
The Keychain treats certain attributes as the uniqueness constraint for an item. For a generic password, the unique key is (kSecAttrService, kSecAttrAccount). If you SecItemAdd with the same service+account, you get errSecDuplicateItem. The pattern is: try Add, on duplicate do Update.
Accessibility — when can the item be read
kSecAttrAccessible controls when the OS will decrypt the item:
| Constant | Readable when... | Backed up? |
|---|---|---|
kSecAttrAccessibleWhenUnlocked |
Device is unlocked | Yes |
kSecAttrAccessibleAfterFirstUnlock |
Device has been unlocked at least once since boot | Yes |
kSecAttrAccessibleWhenUnlockedThisDeviceOnly |
Unlocked, never restored to another device | No |
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly |
After first unlock, never restored | No |
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly |
Unlocked AND device has a passcode set | No |
The ThisDeviceOnly variants are not in iCloud Keychain and are not migrated to a new device on restore. For OAuth refresh tokens, auth secrets, and anything that authenticates the user to your servers, WhenUnlockedThisDeviceOnly is the right default. It means a stolen-and-restored backup cannot resurrect the user's session.
For data you want to use during background tasks (e.g. when a push wakes you up and the device is locked), AfterFirstUnlock is necessary. Picking WhenUnlocked here will produce sporadic, infuriating failures.
A pragmatic Keychain wrapper
This is the wrapper I ship. It handles add/update/read/delete and exposes a KeychainError type for failure cases that actually matter.
import Foundation
import Security
enum KeychainError: Error, Equatable {
case itemNotFound
case duplicateItem
case unexpectedData
case authFailed
case unhandled(OSStatus)
}
struct KeychainStore {
let service: String // e.g. "com.example.myapp"
var accessGroup: String? // for sharing across apps in the same team
// MARK: - Generic password
func set(_ data: Data,
account: String,
accessible: CFString = kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
accessControl: SecAccessControl? = nil) throws {
var query = baseQuery(for: account)
query[kSecValueData as String] = data
if let accessControl {
query[kSecAttrAccessControl as String] = accessControl
} else {
query[kSecAttrAccessible as String] = accessible
}
let status = SecItemAdd(query as CFDictionary, nil)
switch status {
case errSecSuccess: return
case errSecDuplicateItem:
// Update existing
var updateQuery = baseQuery(for: account)
let attrs: [String: Any] = [kSecValueData as String: data]
let updateStatus = SecItemUpdate(updateQuery as CFDictionary,
attrs as CFDictionary)
if updateStatus != errSecSuccess { throw KeychainError.unhandled(updateStatus) }
default:
throw KeychainError.unhandled(status)
}
}
func get(account: String, prompt: String? = nil) throws -> Data {
var query = baseQuery(for: account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
if let prompt {
query[kSecUseOperationPrompt as String] = prompt
}
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
switch status {
case errSecSuccess:
guard let data = item as? Data else { throw KeychainError.unexpectedData }
return data
case errSecItemNotFound: throw KeychainError.itemNotFound
case errSecAuthFailed, errSecUserCanceled: throw KeychainError.authFailed
default: throw KeychainError.unhandled(status)
}
}
func delete(account: String) throws {
let query = baseQuery(for: account)
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unhandled(status)
}
}
private func baseQuery(for account: String) -> [String: Any] {
var q: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
if let accessGroup {
q[kSecAttrAccessGroup as String] = accessGroup
}
return q
}
}
A few notes on what this gets right that naive wrappers miss:
- The add/duplicate/update dance. This is the source of approximately 60% of Keychain bugs in the wild.
- Distinguishing
errSecAuthFailed/errSecUserCanceledfrom other failures. These are expected outcomes when the item is biometric-gated and the user declines. - Surfacing the raw
OSStatusfor the unexpected ones, because the system error messages are useless and you'll want to look the status up inSecurity/SecBase.h.
Biometric / passcode gating on a Keychain item
SecAccessControl lets you require user presence before the item is decryptable:
import LocalAuthentication
func makeBiometricACL() throws -> SecAccessControl {
var err: Unmanaged<CFError>?
guard let acl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
[.biometryCurrentSet, .privateKeyUsage], // privateKeyUsage required only for SE keys
&err
) else {
throw err!.takeRetainedValue() as Error
}
return acl
}
The flag choices matter:
.biometryAny— any enrolled biometric works. The user can add a new face/finger after enrollment and access continues. Convenient, less safe..biometryCurrentSet— invalidates the item if the biometric enrollment changes. If an attacker enrolls their own face, your token becomes unreadable. Use this for high-value secrets..userPresence— equivalent to "biometry or passcode." Most permissive..devicePasscode— passcode only..or/.and— combine flags.
When you read a biometric-gated item, the OS shows the system biometric prompt automatically. Your kSecUseOperationPrompt provides the explanation string.
Keychain sharing across apps
If you have multiple apps from the same team and want them to share a token (single sign-on across a suite), you configure a Keychain Sharing entitlement:
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.example.shared</string>
</array>
Set kSecAttrAccessGroup to "<TeamID>.com.example.shared" (or use $(AppIdentifierPrefix) substitution at build time). Both apps must share the entitlement to read/write that group.
This is also how app extensions share state with their host app. Your Network Extension provider (Part 4) lives in a different process; the only way to share, say, a server URL or a token between the app and the extension is App Groups (for files/UserDefaults) or Keychain Sharing.
iCloud Keychain
If you do not use a ThisDeviceOnly accessibility, and the user has iCloud Keychain enabled, the item syncs across their devices. Set kSecAttrSynchronizable = true to make this explicit (and queryable). Don't put per-device tokens in iCloud Keychain — you'll get session collisions across the user's devices.
3.2 CryptoKit
CryptoKit (iOS 13+) is Apple's modern, Swift-first crypto library. Before CryptoKit, you reached for CommonCrypto with its Unsafe C interface; CryptoKit replaces virtually all of that with type-safe Swift APIs. Use CryptoKit. Don't reach for CommonCrypto unless you're talking to legacy code that demands a specific algorithm CryptoKit doesn't expose.
Hashing
import CryptoKit
let data = "hello world".data(using: .utf8)!
let sha256 = SHA256.hash(data: data)
print(sha256.compactMap { String(format: "%02x", $0) }.joined())
// 256-bit digest of "hello world"
SHA256, SHA384, SHA512 are first-class. Insecure.SHA1 and Insecure.MD5 live in the Insecure namespace as a deliberate code smell — when you read code that uses them, you know it's interfacing with something legacy.
For streaming over a large file:
var hasher = SHA256()
let handle = try FileHandle(forReadingFrom: url)
while case let chunk = try handle.read(upToCount: 64 * 1024), let c = chunk, !c.isEmpty {
hasher.update(data: c)
}
let digest = hasher.finalize()
HMAC and Key Derivation
HMAC is a keyed hash — message authentication. You don't use HMAC directly for encryption; you use it to detect tampering when paired with a cipher (or you use an AEAD cipher that includes the MAC built in).
let key = SymmetricKey(size: .bits256)
let mac = HMAC<SHA256>.authenticationCode(for: data, using: key)
let isValid = HMAC<SHA256>.isValidAuthenticationCode(mac, authenticating: data, using: key)
HKDF (HMAC-based Key Derivation) lets you derive purpose-specific subkeys from a master key. You almost always want this: it's bad practice to use one key for multiple purposes.
let masterKey = SymmetricKey(size: .bits256)
let encKey = HKDF<SHA256>.deriveKey(inputKeyMaterial: masterKey,
info: Data("encryption".utf8),
outputByteCount: 32)
let macKey = HKDF<SHA256>.deriveKey(inputKeyMaterial: masterKey,
info: Data("mac".utf8),
outputByteCount: 32)
For passwords (low-entropy input), HKDF is wrong. Use PBKDF2 via CommonCrypto or, better, Argon2 via a vetted library like libsodium. CryptoKit doesn't ship a password hash primitive — by design, because passwords are tricky.
Symmetric encryption: AES-GCM and ChaChaPoly
Both are AEAD ciphers (Authenticated Encryption with Associated Data). They give you confidentiality and integrity in one operation. You should not be using AES-CBC + HMAC in 2026 unless interfacing with legacy systems.
let key = SymmetricKey(size: .bits256)
let plaintext = "secret message".data(using: .utf8)!
let aad = "context-info".data(using: .utf8)! // authenticated but not encrypted
// Encrypt:
let sealed = try AES.GCM.seal(plaintext, using: key, authenticating: aad)
let envelope = sealed.combined! // nonce(12) || ciphertext || tag(16)
// Decrypt:
let box = try AES.GCM.SealedBox(combined: envelope)
let recovered = try AES.GCM.open(box, using: key, authenticating: aad)
Nonce reuse is fatal. AES-GCM derives its security from never reusing a (key, nonce) pair. AES.GCM.seal without a nonce argument generates a random one for you — this is safe up to ~2^32 messages per key. If you ever pass an explicit nonce, you take on the responsibility for uniqueness, and getting that wrong invalidates the whole cipher.
ChaChaPoly is the alternative. Same shape, better performance on CPUs without AES instructions (older devices, watchOS). Apple's stack will happily use either.
Asymmetric: Curve25519 and P-256
For new code, prefer Curve25519 (X25519 for key agreement, Ed25519 for signatures). It's modern, fast, and side-channel resistant. CryptoKit names them:
| Use | Type |
|---|---|
| Key agreement (Diffie–Hellman) | Curve25519.KeyAgreement.PrivateKey |
| Signatures | Curve25519.Signing.PrivateKey |
// Sender:
let mine = Curve25519.KeyAgreement.PrivateKey()
let theirs = Curve25519.KeyAgreement.PublicKey(rawRepresentation: peerPublic) // received from peer
let shared = try mine.sharedSecretFromKeyAgreement(with: theirs)
let sessionKey = shared.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: Data(),
sharedInfo: Data("app-v1-session".utf8),
outputByteCount: 32
)
// Now use sessionKey with AES.GCM.
For interop with systems that already use P-256/P-384/P-521 (basically: anything talking to enterprise infrastructure, smart cards, or TPMs), use the P256, P384, P521 types. Same APIs.
Putting it together: an end-to-end encryption envelope
A common task: encrypt a payload such that only your server can read it. The server publishes its long-term X25519 public key; the client encrypts each message with a fresh ephemeral key.
import CryptoKit
import Foundation
struct E2EEEnvelope: Codable {
let ephemeralPublic: Data // 32 bytes, X25519
let nonce: Data // 12 bytes
let ciphertext: Data
let tag: Data // 16 bytes
}
struct E2EE {
static func encrypt(_ plaintext: Data,
toServerPublic serverPublic: Data,
aad: Data = Data()) throws -> E2EEEnvelope {
let serverKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: serverPublic)
let ephemeral = Curve25519.KeyAgreement.PrivateKey()
let shared = try ephemeral.sharedSecretFromKeyAgreement(with: serverKey)
let sessionKey = shared.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: ephemeral.publicKey.rawRepresentation, // unique per message
sharedInfo: Data("e2ee-v1".utf8),
outputByteCount: 32
)
let sealed = try AES.GCM.seal(plaintext, using: sessionKey, authenticating: aad)
return E2EEEnvelope(
ephemeralPublic: ephemeral.publicKey.rawRepresentation,
nonce: Data(sealed.nonce),
ciphertext: sealed.ciphertext,
tag: sealed.tag
)
}
}
This pattern gives you per-message forward secrecy: even if the server's long-term private key leaks tomorrow, captured ciphertexts from yesterday remain unreadable, because the actual encryption key came from the ephemeral private key that was thrown away after seal.
3.3 The Secure Enclave
The Secure Enclave (SE) is a separate coprocessor in every modern Apple device with a hardware random number generator and an isolated memory space. Keys generated inside the SE cannot be exported; you can only ask the SE to do things with them (sign, key-agree). This makes them resistant to memory disclosure attacks, jailbreaks, and even compromised kernels.
The SE only supports P-256 keys. Not Curve25519, not RSA. If you need an SE-resident key, it's P-256.
Creating an SE-resident key
import Security
func makeSecureEnclaveKey(tag: String) throws -> SecKey {
var err: Unmanaged<CFError>?
guard let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[.privateKeyUsage, .biometryCurrentSet], // require Face ID/Touch ID to use the key
&err
) else {
throw err!.takeRetainedValue() as Error
}
let attrs: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
kSecAttrKeySizeInBits as String: 256,
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: tag.data(using: .utf8)!,
kSecAttrAccessControl as String: access
]
]
guard let key = SecKeyCreateRandomKey(attrs as CFDictionary, &err) else {
throw err!.takeRetainedValue() as Error
}
return key
}
The tag is how you look the key up later via SecItemCopyMatching. The SE-resident private key itself never appears as bytes anywhere — SecKeyCopyExternalRepresentation will fail on it. You can copy the public key freely.
Signing with an SE key
func sign(_ data: Data, with key: SecKey) throws -> Data {
let algo: SecKeyAlgorithm = .ecdsaSignatureMessageX962SHA256
guard SecKeyIsAlgorithmSupported(key, .sign, algo) else {
throw NSError(domain: "se", code: -1)
}
var err: Unmanaged<CFError>?
guard let sig = SecKeyCreateSignature(key, algo, data as CFData, &err) else {
throw err!.takeRetainedValue() as Error
}
return sig as Data
}
The first time you call this in a session, the system shows the biometric prompt (because of the access control). The signing happens inside the SE; your process never sees the private key.
This is the substrate for things like Passkeys, App Attest, and any mTLS scheme where you want the device key to be truly non-exportable.
3.4 LocalAuthentication
LAContext is the API for asking the user to authenticate. It's used independently of Keychain when you want a "confirm you're still you" prompt — for example, before viewing sensitive in-app content.
import LocalAuthentication
func authenticate(reason: String) async throws -> Bool {
let ctx = LAContext()
ctx.localizedFallbackTitle = "Enter Passcode"
var nsError: NSError?
guard ctx.canEvaluatePolicy(.deviceOwnerAuthentication, error: &nsError) else {
throw nsError ?? NSError(domain: "la", code: -1)
}
return try await withCheckedThrowingContinuation { cont in
ctx.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { ok, err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ok) }
}
}
}
Policies:
.deviceOwnerAuthenticationWithBiometrics— biometrics only..deviceOwnerAuthentication— biometrics, falling back to passcode on failure or unavailability. Almost always the right choice..deviceOwnerAuthenticationWithCompanion— biometrics on the device, or via a paired Apple Watch (macOS).
Two subtleties:
- An
LAContextis single-shot by default. After a successful evaluation, you can use the context for ~10 seconds of "graceful" reuse (e.g., immediately following the prompt, calling another Keychain item with the same context will skip the prompt). After that, you need a new context. - Setting
ctx.touchIDAuthenticationAllowableReuseDuration = 60(or up to 300) extends the no-reprompt window. This is the right knob if you have multiple sequential operations that all want biometric protection.
biometryType — Face ID vs Touch ID vs Optic ID
let ctx = LAContext()
_ = ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
switch ctx.biometryType {
case .faceID: print("Face ID")
case .touchID: print("Touch ID")
case .opticID: print("Optic ID") // Vision Pro
case .none: print("No biometrics")
@unknown default: break
}
Important Info.plist requirement: NSFaceIDUsageDescription must be in Info.plist if you ever invoke Face ID. Without it, the OS terminates your app the moment you try.
3.5 App Transport Security (ATS)
ATS is the system-level network policy that enforces a baseline of safe TLS for every URLSession connection. By default, ATS requires:
- TLS 1.2 or higher
- A trusted certificate chain
- A PFS cipher suite
- Certificate Transparency (for certs issued after a certain date)
You configure ATS via NSAppTransportSecurity in Info.plist. The big knobs:
<key>NSAppTransportSecurity</key>
<dict>
<!-- Global escape hatches. Avoid in production. -->
<key>NSAllowsArbitraryLoads</key> <false/>
<key>NSAllowsArbitraryLoadsInWebContent</key> <true/> <!-- WKWebView only -->
<key>NSAllowsLocalNetworking</key> <true/> <!-- .local hosts -->
<key>NSExceptionDomains</key>
<dict>
<key>legacy.partner.example.com</key>
<dict>
<key>NSExceptionMinimumTLSVersion</key> <string>TLSv1.2</string>
<key>NSExceptionRequiresForwardSecrecy</key> <false/>
<key>NSIncludesSubdomains</key> <true/>
<key>NSRequiresCertificateTransparency</key> <false/>
</dict>
</dict>
</dict>
Rule of thumb: every ATS exception you add weakens your security posture. Audit them quarterly and remove any you can.
App Review will sometimes ask you to justify NSAllowsArbitraryLoads = true. If you genuinely need it (you're building a browser, an RSS reader that talks to arbitrary HTTP feeds, etc.), be ready to explain.
3.6 App Attest and DeviceCheck
These let your server distinguish "request from a real, unmodified copy of my app on a real Apple device" from "request from a script that someone wrote to abuse my API." Both are server-pair APIs — they only make sense if your backend implements verification.
- DeviceCheck (iOS 11+) — gives you two bits of per-device, per-developer state that persist across reinstalls. Useful for "has this device claimed the free trial before?"
- App Attest (iOS 14+) — the heavyweight. Generates a Secure Enclave key bound to your app's identity, attested by Apple. Your server gets cryptographic proof that the request signature was produced by your app's binary running on a real device.
Sketch:
import DeviceCheck
let service = DCAppAttestService.shared
guard service.isSupported else { return }
let keyId = try await service.generateKey() // SE-backed
let challenge = try await fetchChallengeFromServer() // 32-byte random
let attestation = try await service.attestKey(keyId, clientDataHash: Data(SHA256.hash(data: challenge)))
// Send (keyId, attestation, challenge) to your server.
// Server verifies with Apple's anchor and stores keyId for this user.
After attestation, every request your client makes can be asserted: you sign a hash of the request body with the same SE key, and your server verifies the assertion. The assertion proves "the request was assembled inside the genuine app." This is the strongest anti-abuse signal Apple ships.
3.7 What to remember from Part 3
- Use
kSecAttrAccessibleWhenUnlockedThisDeviceOnlyfor tokens.AfterFirstUnlockfor background-task secrets. NeverAlways. - Add/Update dance for Keychain items. Always check
errSecAuthFailedvs other errors. - CryptoKit for everything new. AES-GCM or ChaChaPoly for symmetric; Curve25519 for new asymmetric; P-256 if you need Secure Enclave or interop.
- Secure Enclave keys are P-256, non-exportable, biometric-gateable. Use them for high-value device-bound keys.
- ATS exceptions are a debt. Track them, remove them.
- App Attest is the gold standard for anti-abuse if you have the backend to verify it.
Part 4 — The Network Extension Framework
The Network Extension (NE) framework is how third-party code participates in iOS's networking stack. VPN clients, content filters, encrypted-DNS providers, captive-portal helpers — all of them are NE extensions. Building one is qualitatively different from building a normal iOS app: you write an app extension that lives in its own process, has a tightly-bounded memory budget, and runs under tight system supervision.
This part is a tour of the framework: what each provider type is for, what entitlements you need, and a deep walk through a Packet Tunnel provider — the canonical example.
4.1 Provider Types
| Provider | What it does | Typical product |
|---|---|---|
NEPacketTunnelProvider |
A full system VPN. The OS routes packets to your provider, which encapsulates them and sends them on. | Personal VPNs, enterprise VPNs (IKEv2, WireGuard, custom protocols) |
NEAppProxyProvider (TCP or UDP variants) |
A per-app or system flow proxy. Apps' flows arrive as flow objects, not raw packets. | SASE clients, enterprise zero-trust proxies |
NEFilterDataProvider + NEFilterControlProvider |
Inspects and allows/blocks traffic at the flow level. | Parental controls, school filters, anti-malware |
NEDNSProxyProvider |
Receives all DNS queries from the device and answers them however you want. | Custom DNS, ad blockers |
NEDNSSettingsManager (iOS 14+) |
Configures system-wide encrypted DNS (DoH/DoT) without writing a provider. | Easy "use my DoH server" apps |
NEHotspotHelper |
Helps the system join a specific Wi-Fi hotspot (captive portal flow). | Carrier Wi-Fi helpers |
NEFilterPacketProvider |
Packet-level filter (macOS only on most accounts) | Network firewalls |
Every one of these is an app extension, which means:
- Separate target in Xcode (template: "Network Extension")
- Separate
Info.plistdeclaring the extension point - Separate
entitlementsfile - Communicates with your main app via App Groups, Keychain Sharing, and (post-launch)
IPCvia the manager APIs
Entitlement required: com.apple.developer.networking.networkextension. This is a managed entitlement — you cannot just toggle it in Xcode. You have to request it from Apple, with a justification, and get approved. Plan for this when scoping a project; it can take weeks.
4.2 The Configuration / Provider Split
Every NE provider has two halves:
- Configuration: lives in the containing app. The app uses
NETunnelProviderManager(orNEFilterManager,NEDNSProxyManager, etc.) to install, enable, and configure a profile. The first time, the user gets a permission prompt: "App X wants to add VPN configurations." - Provider: lives in the extension target. The system spawns it when needed (when the VPN is "on" and the OS wants to send packets through it). The extension reads its configuration from the OS via a
protocolConfigurationobject that was set by the containing app.
You almost always want to ship a singleton manager helper for the app side:
import NetworkExtension
final class TunnelManager {
static let shared = TunnelManager()
private var manager: NETunnelProviderManager?
func load() async throws {
let managers = try await NETunnelProviderManager.loadAllFromPreferences()
if let existing = managers.first {
self.manager = existing
} else {
self.manager = NETunnelProviderManager()
}
}
func install(serverAddress: String, sharedKey: Data) async throws {
guard let manager = manager else { return }
let proto = NETunnelProviderProtocol()
proto.providerBundleIdentifier = "com.example.app.tunnel" // your extension's bundle id
proto.serverAddress = serverAddress
// Custom config dictionary — your extension reads this:
proto.providerConfiguration = [
"endpoint": serverAddress,
"psk": sharedKey
]
// For mTLS-style auth:
// proto.identityReference = <persistent reference from SecItemCopyMatching>
manager.protocolConfiguration = proto
manager.localizedDescription = "Example VPN"
manager.isEnabled = true
// On-demand rules — the OS will auto-start the tunnel under these conditions:
let rule = NEOnDemandRuleConnect()
rule.interfaceTypeMatch = .any
manager.onDemandRules = [rule]
manager.isOnDemandEnabled = true
try await manager.saveToPreferences()
// saveToPreferences triggers the user permission prompt the first time.
// Reload after save (system normalizes the object):
try await manager.loadFromPreferences()
}
func start() throws {
try manager?.connection.startVPNTunnel()
}
func stop() {
manager?.connection.stopVPNTunnel()
}
var status: NEVPNStatus {
manager?.connection.status ?? .invalid
}
}
A few things to internalize:
saveToPreferences()triggers the user prompt the first time. The user can deny. Handle that gracefully.providerConfigurationis a[String: Any]dictionary that you serialize into the system's preferences plist. It cannot contain arbitrary objects — only plist-safe types. For secrets, usepasswordReference(a Keychain persistent reference) instead.identityReferenceis a Keychain persistent reference to aSecIdentity(cert + private key). This is how MDM-deployed mTLS VPN configs work without your code ever holding the private key.- The
connection.statusproperty is observable viaNSNotification.Name.NEVPNStatusDidChange— wire that up in your UI.
4.3 Inside a Packet Tunnel Provider
The extension target's principal class subclasses NEPacketTunnelProvider. The system calls four key methods:
import NetworkExtension
class PacketTunnelProvider: NEPacketTunnelProvider {
override func startTunnel(options: [String: NSObject]?,
completionHandler: @escaping (Error?) -> Void) {
// 1. Read configuration
guard let proto = self.protocolConfiguration as? NETunnelProviderProtocol,
let providerConfig = proto.providerConfiguration,
let endpoint = providerConfig["endpoint"] as? String else {
completionHandler(NSError(domain: "tunnel", code: 1))
return
}
// 2. Connect to the remote (handshake your protocol, auth, key-exchange...)
connectToRemote(endpoint: endpoint) { [weak self] result in
guard let self else { return }
switch result {
case .failure(let error):
completionHandler(error)
case .success(let session):
// 3. Configure the virtual interface
let settings = self.makeSettings()
self.setTunnelNetworkSettings(settings) { error in
if let error {
completionHandler(error)
return
}
// 4. Begin shovelling packets
self.startPacketFlow(session: session)
completionHandler(nil)
}
}
}
}
override func stopTunnel(with reason: NEProviderStopReason,
completionHandler: @escaping () -> Void) {
// tear down the remote session, flush, complete
completionHandler()
}
override func handleAppMessage(_ messageData: Data,
completionHandler: ((Data?) -> Void)?) {
// IPC from the containing app via NETunnelProviderSession.sendProviderMessage
completionHandler?(/* response */ nil)
}
override func sleep(completionHandler: @escaping () -> Void) {
// device entering low-power mode; reduce traffic, persist state
completionHandler()
}
override func wake() {
// device coming out of low-power mode
}
// MARK: -
private func makeSettings() -> NEPacketTunnelNetworkSettings {
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "10.0.0.1")
let ipv4 = NEIPv4Settings(addresses: ["10.8.0.2"], subnetMasks: ["255.255.255.0"])
ipv4.includedRoutes = [NEIPv4Route.default()] // route all traffic through tunnel
// ipv4.excludedRoutes = [NEIPv4Route(destinationAddress: "192.168.0.0", subnetMask: "255.255.0.0")] // split tunnel
settings.ipv4Settings = ipv4
let dns = NEDNSSettings(servers: ["10.8.0.1"])
dns.matchDomains = [""] // empty string => default DNS for all queries
settings.dnsSettings = dns
settings.mtu = 1400 // leave headroom for your encapsulation overhead
return settings
}
private func startPacketFlow(session: TunnelSession) {
// Read packets from the system, encrypt, send to remote
packetFlow.readPackets { [weak self] packets, protocols in
// packets is [Data] of raw IP packets; protocols is [NSNumber] of AF_INET or AF_INET6
self?.handleOutbound(packets: packets, protocols: protocols, session: session)
self?.startPacketFlow(session: session) // recurse to continue reading
}
// Read packets from remote, decrypt, hand back to the system
session.onInboundPacket = { [weak self] decryptedPacket in
// tell the system "here's a packet that arrived on the tunnel"
self?.packetFlow.writePackets([decryptedPacket], withProtocols: [AF_INET as NSNumber])
}
}
private func handleOutbound(packets: [Data], protocols: [NSNumber], session: TunnelSession) {
for (i, packet) in packets.enumerated() {
let proto = protocols[i].int32Value // AF_INET or AF_INET6
session.send(packet: packet, protocol: proto)
}
}
private func connectToRemote(endpoint: String,
completion: @escaping (Result<TunnelSession, Error>) -> Void) {
// your wire-protocol code: open NWConnection, handshake, set up cipher state
}
}
/// Placeholder for whatever session/transport your protocol uses.
final class TunnelSession {
var onInboundPacket: ((Data) -> Void)?
func send(packet: Data, protocol proto: Int32) { /* ... */ }
}
What setTunnelNetworkSettings does
This is the call that brings the virtual interface (utun0-style) up inside the OS. Once it completes successfully, the system will start routing matching packets through your packetFlow. Until it does, no packets flow.
A few subtleties:
includedRoutesof[NEIPv4Route.default()]means "all traffic." For split tunneling, list only the subnets you care about and the OS will route the rest normally.dnsSettings.matchDomains = [""]is special: empty string is the catch-all, meaning all DNS goes to your provider. To tunnel only specific domains, list them here.mtumust account for your protocol's overhead. Set it too high and the OS hands you packets that don't fit, leading to drops or per-packet fragmentation.- You can call
setTunnelNetworkSettings(nil) { }followed by anothersetTunnelNetworkSettings(newSettings) { }to re-key the tunnel (e.g., after the remote pushes a new DNS server).
Memory limits — the constraint that shapes everything
Packet Tunnel Providers run with a hard memory cap. On iOS this is roughly 50 MB (Apple has changed this over the years; treat it as "tight"). Your provider will be killed without ceremony if it exceeds the limit.
Consequences:
- Don't load big libraries. Compile only what you need.
- Don't buffer packets unboundedly. If your encrypted output is slow, drop. Apply backpressure.
- Profile with the Allocations instrument explicitly attached to the extension process, not the app.
- Be careful with Swift's reference cycles — every leaked retain is forever.
Equally important: the OS will kill your extension when it thinks the user no longer wants it (Settings → VPN toggle), when the device is rebooting, when memory is tight system-wide. Your provider must persist anything it cares about (session keys, sequence numbers, counters) to a file in the App Group container so it can resume cheaply.
IPC with the containing app
Two mechanisms:
-
NETunnelProviderSession.sendProviderMessage(_:)— the app sends bytes to the provider; the provider'shandleAppMessage(_:)runs. Synchronous-style: a single round trip. Useful for "fetch current connection stats" or "rotate this key." -
App Group container — shared file system area between app and extension. Use for logs, persisted state, big payloads. Don't use for high-frequency signalling.
Example app side:
let session = manager.connection as! NETunnelProviderSession
try session.sendProviderMessage("get-stats".data(using: .utf8)!) { response in
guard let response else { return }
let stats = try? JSONDecoder().decode(TunnelStats.self, from: response)
}
Extension side:
override func handleAppMessage(_ data: Data, completionHandler: ((Data?) -> Void)?) {
let cmd = String(data: data, encoding: .utf8)
switch cmd {
case "get-stats":
let stats = TunnelStats(bytesSent: bytesSent, bytesRecv: bytesRecv)
completionHandler?(try? JSONEncoder().encode(stats))
default:
completionHandler?(nil)
}
}
Logging from an extension
print() from an extension goes... somewhere unhelpful. Use os_log (or the unified logging API via Logger) so you can grep with log stream --predicate 'subsystem == "com.example.tunnel"' from the Console app or Console.app on macOS:
import os.log
let log = Logger(subsystem: "com.example.tunnel", category: "core")
log.info("tunnel started, endpoint=\(endpoint, privacy: .public)")
Privacy qualifiers matter — by default, Logger redacts string arguments (you'll see <private> in production). For things you want visible, mark .public. For PII, leave the default.
4.4 App Proxy Providers
NEAppProxyProvider is the right shape when you want flows, not packets. Instead of getting raw IP datagrams, the system gives you NEAppProxyFlow objects (NEAppProxyTCPFlow or NEAppProxyUDPFlow). You read application-level bytes, do whatever (forward to a remote, inspect, modify), and write the response back.
This is the right tool for:
- Building a zero-trust proxy (intercept all traffic from a managed app and route through an authenticated tunnel)
- Per-app VPN configurations
- Application-aware traffic inspection
A bit different from packet tunnel: you're never dealing with IP headers. The system handles them.
class FlowProvider: NEAppProxyProvider {
override func startProxy(options: [String: Any]?,
completionHandler: @escaping (Error?) -> Void) {
// configure the proxy (e.g. set up upstream connection)
completionHandler(nil)
}
override func handleNewFlow(_ flow: NEAppProxyFlow) -> Bool {
if let tcpFlow = flow as? NEAppProxyTCPFlow {
handleTCP(tcpFlow)
return true
}
if let udpFlow = flow as? NEAppProxyUDPFlow {
handleUDP(udpFlow)
return true
}
return false // let the system handle it
}
private func handleTCP(_ flow: NEAppProxyTCPFlow) {
flow.open(withLocalEndpoint: nil) { error in
guard error == nil else { flow.closeReadWithError(error); return }
// open your upstream NWConnection to the actual destination
// shovel bytes back and forth using flow.readData / flow.write
}
}
private func handleUDP(_ flow: NEAppProxyUDPFlow) {
// similar
}
}
Returning true from handleNewFlow means "I'm taking this flow." Returning false means "let the system handle it normally." For per-app VPN configs, the system only delivers flows from the apps listed in the MDM-deployed app-proxy config (more on this in Part 5).
4.5 Content Filter Providers
NEFilterDataProvider + NEFilterControlProvider is the API for "watch every flow, decide allow/block/inspect." It's what parental control apps and corporate filtering products are built on.
Two providers because of the iOS extension constraints:
- The Data Provider runs in a tight sandbox with no network access of its own. It sees every flow's metadata and (optionally) data, and returns a
NEFilterDataVerdict. - The Control Provider has network access. It's where you go fetch rules from your server, build the blocklist the Data Provider will use, share state via App Group container.
The Data Provider is called for every flow's handleNewFlow, then for inbound and outbound data. You can:
.allow()— let it through.drop()— kill the flow.needRules()— defer to the Control Provider; the Data Provider gets recalled later
To install a filter, you need the device to be MDM-supervised (corporate/educational deployment) or for the app to be marked as a parental-control app. Consumer apps generally cannot install a system-wide filter on an unmanaged device.
4.6 DNS Proxy and DNS Settings
Two flavors:
NEDNSProxyProvider
You write a full DNS proxy. The system hands you every DNS query as a flow; you answer it however you like. Used for:
- Custom resolvers
- Network-wide ad/tracker blocking (Pi-hole-style)
- Hybrid DoH/cached resolution
Memory budget and constraints are similar to packet tunnel.
NEDNSSettingsManager (iOS 14+)
Much simpler. You ship an app that, when launched, installs a system-wide DoH or DoT configuration:
import NetworkExtension
func installDoH() async throws {
let manager = NEDNSSettingsManager.shared()
try await manager.loadFromPreferences()
let dohSettings = NEDNSOverHTTPSSettings(servers: ["1.1.1.1", "1.0.0.1"])
dohSettings.serverURL = URL(string: "https://cloudflare-dns.com/dns-query")
manager.dnsSettings = dohSettings
manager.localizedDescription = "My DoH Provider"
try await manager.saveToPreferences()
}
After save, the user has to enable it in Settings → General → VPN, DNS & Device Management → DNS. Once enabled, all device DNS goes via your configured DoH endpoint. No extension code required — Apple's stack does the work.
4.7 Hotspot Helpers
NEHotspotHelper is for carrier Wi-Fi: you implement an extension that, when the device sees a network you recognize (specific SSID, specific authentication characteristics), assists with the join. EAP-SIM negotiation, captive-portal walkthroughs, that sort of thing. This is a niche, carrier-partner feature and entitlement.
There's also NEHotspotConfigurationManager (no extension, just app code) which lets your app configure the device to join a specific Wi-Fi network programmatically. Useful for "set up my IoT device" apps:
import NetworkExtension
let config = NEHotspotConfiguration(ssid: "MyDevice-1234", passphrase: "abc12345", isWEP: false)
config.joinOnce = true
NEHotspotConfigurationManager.shared.apply(config) { error in /* ... */ }
4.8 What to remember from Part 4
- NE extensions are real, separate processes with tight memory budgets and a managed entitlement that takes time to get.
- The configuration/provider split is a hard architectural line. App configures, provider runs.
- For packet tunnels, settings must be set before packets flow. Cap MTU below your overhead.
- Use App Groups for persisted state and Keychain Sharing for secrets. Use
sendProviderMessagefor low-bandwidth control IPC. - For new "use this DoH server" features,
NEDNSSettingsManageris the easy path — no extension code at all.
Part 5 — MDM Systems and Managed App Configuration
MDM stands for Mobile Device Management. From the iOS-developer perspective, MDM is the system by which an enterprise (or school) remotely configures, inspects, and partially controls devices, and by which the apps you ship can read enterprise-supplied configuration. You don't build MDM as an app developer (that's vendor work — Jamf, Intune, Workspace ONE, Mosyle, Kandji); you integrate with it.
This part gives you the mental model and the integration points.
5.1 The MDM Protocol — high level
Apple's MDM protocol is a documented HTTP-based protocol where the MDM server (operated by the vendor or in-house) talks to the device via Apple's push infrastructure.
The shape of it:
- Device enrolls (more on this below). It now has an MDM profile installed, which contains the server URL, an identity (cert + key in the device's Keychain), and a topic for APNs.
- The MDM server uses APNs to send a push notification to the device — not with content, just a wake-up signal.
- On receiving the push, the device's MDM client connects (HTTPS, mTLS-authenticated using the identity from step 1) to the server and asks "what do you want?"
- The server returns a command:
InstallProfile,InstallApplication,DeviceInformation,Restrictions,EraseDevice,RemoveProfile, etc. - The device executes the command and reports back the result.
The whole thing is XML-plist-shaped. The device-to-server channel is always TLS with the device-side certificate as client auth — the server knows the device's identity cryptographically. This is also why the MDM identity in the Keychain is high-value and protected.
If you want to actually read the protocol, Apple publishes it in the Device Management reference (formerly the "Mobile Device Management Protocol Reference"). You don't need to read it cover-to-cover to integrate with MDM as an app developer, but skimming it once explains a lot of behavior.
5.2 Enrollment Flows
Different ways a device becomes "managed":
Apple Business Manager / Apple School Manager (ABM / ASM)
The strongest form. Devices purchased through corporate Apple channels (or migrated in) are tied to the org's ABM tenant. The first time the device is set up, the Setup Assistant contacts Apple, sees the device is enrolled in ABM, and pushes it to the org's MDM. The user has no opt-out. This is Automated Device Enrollment (formerly "DEP — Device Enrollment Program").
ABM-enrolled devices can be marked Supervised. Supervised devices unlock additional restrictions and capabilities for the MDM (mandatory app installation, single-app mode, content filter installation without user consent, etc.).
User Enrollment (BYOD)
iOS 13+ introduced a privacy-preserving enrollment flow for personal devices. The user voluntarily enrolls (via Settings → General → VPN & Device Management → adding an MDM profile). The org gets a logical, encrypted partition: managed apps, managed accounts, managed configs. The IT admin cannot wipe the device, cannot read personal data, cannot install profiles outside the managed partition.
This is the dominant enrollment flow for "bring your own iPhone to work" scenarios.
Device Enrollment (legacy "BYOD")
Older approach: install a profile, manual enrollment, full device management. Largely replaced by User Enrollment for personal devices.
Account-Driven Enrollment (iOS 15+ for User Enrollment, iOS 16.1+ for Device Enrollment)
Newest variant. The user enters their Managed Apple ID in Settings → Apple ID → Sign In and the system pulls the MDM enrollment from the org. Smooth experience, no profiles to install manually.
5.3 Configuration Profiles
Even outside full MDM, configuration profiles are how iOS gets configured at scale. A .mobileconfig file is a plist (signed, optionally encrypted) containing one or more payloads — Wi-Fi credentials, VPN configs, mail accounts, restrictions, certificates, MDM enrollment itself, app config dictionaries.
You'll see these in:
- The MDM server pushing them via
InstallProfile. - IT admins distributing them via email/intranet for manual install.
- Apple Configurator (macOS) creating them for one-off deployment.
A profile is roughly:
<plist version="1.0">
<dict>
<key>PayloadType</key> <string>Configuration</string>
<key>PayloadIdentifier</key> <string>com.example.config.wifi</string>
<key>PayloadUUID</key> <string>...</string>
<key>PayloadDisplayName</key> <string>Corp Wi-Fi</string>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key> <string>com.apple.wifi.managed</string>
<key>SSID_STR</key> <string>CorpNet</string>
<key>EncryptionType</key> <string>WPA2</string>
<key>EAPClientConfiguration</key>
<dict>
<key>AcceptEAPTypes</key> <array><integer>13</integer></array> <!-- EAP-TLS -->
<key>PayloadCertificateAnchorUUID</key>
<array><string>uuid-of-cert-payload</string></array>
</dict>
</dict>
<!-- more payloads, e.g. a cert payload, a VPN payload referencing this cert -->
</array>
</dict>
</plist>
The MDM vendor's UI is usually a GUI on top of this structure.
5.4 Managed App Configuration — your app's hook into MDM
This is the integration point that matters most for app developers. The MDM can push a dictionary of configuration to a specific managed app on the device. Your app reads it from UserDefaults.
The MDM sets a payload of type com.apple.app.managed, keyed by your bundle ID, containing a free-form dictionary. On the device, that dictionary appears in UserDefaults.standard under the key com.apple.configuration.managed.
final class ManagedConfig {
static let shared = ManagedConfig()
static let key = "com.apple.configuration.managed"
private(set) var config: [String: Any] = [:]
init() {
refresh()
// Observe changes — the MDM can push updates at any time
NotificationCenter.default.addObserver(
self,
selector: #selector(refresh),
name: UserDefaults.didChangeNotification,
object: nil
)
}
@objc private func refresh() {
config = UserDefaults.standard.dictionary(forKey: Self.key) ?? [:]
NotificationCenter.default.post(name: .managedConfigChanged, object: nil)
}
// Convenience accessors:
func string(_ name: String) -> String? { config[name] as? String }
func bool(_ name: String) -> Bool? { config[name] as? Bool }
func int(_ name: String) -> Int? { config[name] as? Int }
func data(_ name: String) -> Data? { config[name] as? Data }
}
extension Notification.Name {
static let managedConfigChanged = Notification.Name("ManagedConfigChanged")
}
There's also a managed app feedback channel: your app can write back to UserDefaults under com.apple.feedback.managed, and the MDM can read it. Use this for "current version," "last login time," "license state" — anything IT needs to see across the fleet.
let feedback: [String: Any] = [
"appVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "",
"lastSignInUTC": ISO8601DateFormatter().string(from: Date()),
"premiumLicenseActive": entitlements.isPremium
]
UserDefaults.standard.set(feedback, forKey: "com.apple.feedback.managed")
What kinds of keys should you expose?
Anything that an enterprise admin would want to control centrally. Typical examples:
- Server URL — let the admin point the app at a tenant-specific backend.
- Allowed login domains — restrict SSO to the corporate domain.
- Feature flags — disable/enable features per org.
- Default policies — biometric-required-on-launch, idle-lock timeout.
- Certificate UUIDs — point the app at a specific cert in the keychain that came in via profile.
Document the schema. AppConfig.org is an industry consortium that publishes XML schemas describing the supported keys for an app, which MDM vendors can ingest to build per-app UIs. Shipping an AppConfig spec dramatically improves your app's usability in enterprise deployments. It's a small upfront cost (a few hours to write the XML) for a much better experience for IT admins.
Reading managed certificates and identities
When an MDM pushes a com.apple.security.pkcs12 payload, the cert+key lands in the device's Keychain. Your app can locate it by attributes (typically the certificate's CN or by a UUID coordinated with the profile):
let query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecMatchSubjectContains as String: "corp-mtls-client",
kSecReturnRef as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let identity = item else { return }
let cred = URLCredential(identity: identity as! SecIdentity,
certificates: nil,
persistence: .none)
You can then use this identity for mTLS as shown in Part 2.
5.5 Per-App VPN
A particularly useful MDM construct: the admin configures a VPN payload and binds it to specific managed apps. Traffic from those apps goes through the VPN; traffic from other (non-managed) apps doesn't. This is implemented by the MDM payload referencing your app's bundle ID in the VPN config's app-association table; the network stack does the rest.
There's no special code in your app for this — it just works. But test it. The behavior is URLSession-level, transparent. The only thing to know: if the VPN is misconfigured or down, requests from your app will fail in ways that look like generic connectivity errors. Surfacing NWPath.unsatisfiedReason and URLSessionTaskMetrics makes these much easier to debug in the field.
5.6 Restrictions and Capabilities You Inherit
Once the device is managed, the MDM can apply restrictions that affect your app. The big ones:
- Managed Open-In — files received in managed apps cannot be opened by personal apps, and vice-versa. The
UIActivityViewControllershare sheet honors this. - Managed Pasteboard — the system pasteboard is partitioned. Cut/copy in a managed app cannot be pasted in personal apps (and vice versa).
- Managed Apple Account / Managed Apple ID — in some configs the device has a managed sign-in distinct from personal iCloud.
- App Allow / Disallow Lists — supervised devices can have an allowlist of bundle IDs.
You don't usually have to write code for these — the system enforces them around you. But you should know they exist, because users will report bugs like "I can't paste from Outlook into our app" and the answer is "you can't; that's working as intended."
5.7 Apple Configurator and the testing loop
You do not need an MDM server to develop against managed app configuration. Apple Configurator (macOS, free) lets you push a config dictionary to a USB-tethered device:
- In Apple Configurator, select your device.
- Add → Apps → choose your app (or use Actions → Add → Apps).
- Once installed, use iMazing Profile Editor or hand-write a
.mobileconfigwith acom.apple.app.managedpayload pointing at your bundle ID. - Drag the profile onto the device in Configurator.
- Verify your app reads
UserDefaults.standard.dictionary(forKey: "com.apple.configuration.managed").
For Network Extension testing under enterprise conditions, deploy through Configurator or a free-tier Jamf or Mosyle (or simulate with a hand-crafted profile signed with your dev cert).
5.8 What to remember from Part 5
- App developers don't build MDM — they integrate. The integration is reading
UserDefaults["com.apple.configuration.managed"]and writing["com.apple.feedback.managed"]. - Document your keys with an AppConfig schema. It makes you the easy-to-deploy app in enterprise procurement.
- Per-App VPN binding is configured outside your app. Your code just sees
URLSessionwork differently in different deployment contexts. - Managed Open-In, managed pasteboard, and supervised restrictions are system-enforced. Test under managed conditions before you ship a B2B SKU.
Part 6 — Performance Tools: Instruments and Power Profiler
Performance work has two sides: diagnosis (something is slow or burning battery, find out why) and regression prevention (catch performance bugs before users do). Both rely on the same tool set. This part walks through what each tool does, what its output means, and how to wire your own code into the unified profiling story with signposts.
6.1 The Instruments App, Conceptually
Instruments is a profiler shell. It collects data from one or more instruments (the lowercase plural — Time Profiler, Allocations, etc.), each of which records a particular kind of trace. A "trace document" is a collection of timelines you can scrub, filter, and drill into.
You launch it from Xcode (Product → Profile, or ⌘I), which builds your app in Release with debug symbols and attaches Instruments. Always profile a Release build. Debug builds disable optimizations, enable assertions, ship _NSAsserts, and otherwise lie about real-world performance.
The base unit of measurement in Instruments is the sample — a snapshot at a point in time. Most instruments are sampling-based; over a session, the sampling rate (typically 1 ms or so) builds up a statistical view. This is why the bottom of the call tree is "stochastic" — the profiler didn't catch every nanosecond, just enough to draw a representative picture.
6.2 Time Profiler — the first tool you should learn
The Time Profiler samples the CPU's call stack at a configurable rate (default 1 kHz). What you get back is "during this trace, here's how much CPU time was spent in each function."
Reading the call tree
The output is a hierarchy: top-level threads, expanding into call stacks, with two key columns:
- Weight — total time spent in this function or its callees (inclusive). Wide-and-deep stacks look big here.
- Self Weight — time spent in this function alone (exclusive). Hotspots in leaf code look big here.
The first move when you open a Time Profile is usually:
- Filter to your process (multi-process traces are noisy).
- Invert the call tree (you see leaf hotspots at the top — usually where the actual CPU work is).
- Hide system libraries (
Edit → Display Settings → Hide System Libraries) so you see only your code. - Filter the time range to the spike you care about — drag-select on the timeline.
What you're looking for:
- Single hot leaf — one function is dominating. The optimization is obvious.
- Wide tree of small contributors — death by a thousand cuts; means a systemic issue (too many small allocations, too many calls into a property accessor, etc.). Refactor.
- Unexpected callers — "why is
formatDateshowing up at 12% of CPU?" — usually someone is calling it in a tight loop.
Threads and Quality of Service
Instruments visualizes per-thread state and color-codes by QoS class. A thread that's marked .userInteractive running for a long time is a smell — it should be doing tiny pieces of work on the main thread, not heavy computation. The "Threads" view in the Time Profiler tells you instantly when the main thread (com.apple.main-thread) is hot.
Why your main thread is hot
The classic culprits, ranked by my experience:
- JSON parsing and
JSONDecoderdone synchronously on a tap. - Image decoding — UIKit decodes JPEGs/PNGs on the main thread the first time they're drawn unless you decode-ahead.
- Layout — Auto Layout solve over a giant constraint graph during scroll.
- String formatting —
DateFormatteris shockingly expensive; reuse them, cache them, never instantiate in a cell config. - Implicit
UIViewaccess from a background thread that triggers a sync hop back to main (this is rare but vicious).
6.3 Allocations and Leaks
The Allocations instrument tracks every heap allocation in your process and tags it with a category (Malloc 16 Bytes, NSConcreteData, NSMutableArray, your Swift classes). It gives you:
- Persistent bytes — what's still alive.
- Total bytes / count — everything ever allocated, including freed.
- Allocation Lifespan — interactively filter to "live for 5+ seconds" or "allocated and freed within 100 ms" to see different patterns.
The two regressions you find here
- Growth over time — persistent bytes climb during a repeatable interaction (scroll a feed, open and close a screen 10 times). Something is being retained that shouldn't be.
- Allocation churn — total bytes climb fast during an interaction but persistent stays flat. You're allocating-and-freeing millions of small objects. Often this kills frame rate before it kills memory.
To find growth, use Mark Generation (the "Mark" button). Mark, do an interaction, mark again. The diff shows what was allocated and survived between marks. The leaked-object pattern is "I open a screen, close it, mark — the screen's view controller is still in the generation."
Leaks vs cycles vs zombies
- Leaks instrument finds memory the runtime considers unreachable but didn't free (mostly relevant for C/Obj-C code paths that mismanage retain counts; rare in pure Swift).
- Cycles in Swift are far more common. Use Xcode's Debug Memory Graph (Debug → Memory Graph) — it's faster than Instruments for finding "this object's retain graph has a cycle." Look for the purple ⚠️ icon.
- Zombies instrument keeps deallocated objects alive as "zombie" sentinels so you can identify use-after-free crashes. Enable when you have an inexplicable EXC_BAD_ACCESS.
Anatomy of finding a retain cycle in a SwiftUI/@Observable world
You'd think SwiftUI's value-type model would make cycles impossible. It doesn't:
- A closure captures
selfstrongly inside a class. - An
@Observablemodel holds a reference to a coordinator that holds a reference back. - A combine
sinkretains its subscription, which retains its handler, which retainsself.
The diagnosis loop:
- Open Memory Graph.
- Find your suspect type (filter by name in the left sidebar).
- Look at its Inbound Refs. If something is retaining it that shouldn't be — and it's marked with the cycle warning — that's your cycle.
- Fix by making one side
[weak self](closures) orweak/unowned(properties).
6.4 Hangs and Hitches — the Animation Instruments
iOS classifies main-thread stalls in three tiers:
- Hang — main thread blocked for ≥250 ms. The UI is frozen. Users feel this immediately.
- Microhang — 100–250 ms. The UI is sluggish.
- Hitch — a frame didn't render on time (frame budget at 60 Hz is 16.67 ms, at 120 Hz on ProMotion devices it's 8.33 ms).
Instruments has dedicated templates:
- Hangs template — best for finding the "tap and nothing happens for half a second" problem.
- Animation Hitches template — for "scrolling stutters" and "view transition jitter."
Both surface the offending stacks. The fix is almost always "move that work off the main thread" or "do less of it."
Xcode's built-in hang detection
In addition to Instruments, Xcode 14+ ships with a runtime Hangs reporter — set DEBUG_HANG_REPORTING and Xcode logs hangs in real-time as you use your debug build. Combined with the Organizer → Hangs view (which surfaces hang reports from TestFlight and the App Store), you get hang telemetry across the funnel: dev, beta, production. Wire this into your launch checklist.
6.5 The Network Instrument
The Network instrument (and the "Network Connections" inspector inside Instruments) shows:
- Every
URLSessiontask: URL, status, size, timing breakdown. - Every
nw_connection_t(Network.framework) and its state transitions. - DNS resolutions and their latency.
- TLS handshakes and their version/suites.
This is the right place to look when "my app's network feels slow" — you get the same data URLSessionTaskMetrics would give you in code, but cross-correlated with CPU, memory, energy, and your own signposts (next section).
6.6 os_signpost — custom Instruments events
You can teach Instruments about your app's units of work. Use os_signpost (or the OSSignposter Swift wrapper) to bracket regions of code, name them, and attach metadata. They show up as flags or intervals on the timeline next to all the system-level instruments.
import OSLog
let signposter = OSSignposter(subsystem: "com.example.app", category: "feed")
// Mark an interval — open and close
let id = signposter.makeSignpostID()
let state = signposter.beginInterval("loadPage", id: id, "page=\(pageIndex)")
await fetchPage(pageIndex)
signposter.endInterval("loadPage", state)
When you record a trace in Instruments and add the os_signpost instrument, you see your loadPage intervals on the timeline. Pair them with Time Profiler / Allocations / Network instruments running simultaneously, and you can answer questions like: "during a loadPage interval, what was the main-thread CPU doing, and what network requests were in flight?"
For events (no duration), use emitEvent:
signposter.emitEvent("cacheHit", "key=\(key)")
Best practice: every long-running operation in your app (load a page, decode an image, sign a request, encrypt a payload) should have a signpost. The cost is negligible (signposts are nearly free in release builds when no profiler is attached), and the diagnostic uplift is dramatic.
6.7 Energy and Power Profiling
Battery is the resource users notice most acutely. There are three tools you'll use, in increasing depth.
The Energy Gauge in Xcode
While debugging, the Energy gauge in Xcode's Debug Navigator (left sidebar) gives a live, coarse view of energy use bucketed into CPU, network, location, GPU, and overhead. Useful for "is my code actually idle when I think it is."
Energy Log instrument in Instruments
A trace template that records:
- CPU activity by core
- Network activity (bytes per second by interface)
- Display brightness
- Disk I/O
- GPS / location activity
- Bluetooth state
What you're hunting:
- CPU "always-on" patterns — your code is doing tiny bits of work every second when it should be silent.
- Network in the background — you have a timer-based poll that should be a push notification.
- GPS active when it shouldn't be — you forgot to stop the location manager.
MetricKit — production telemetry
MetricKit (iOS 13+) gives you anonymized per-day metric reports from users in the field. You get:
MXCPUMetric— CPU time used by your appMXMemoryMetric— average/peak suspended memoryMXAppRunTimeMetric— foreground/background timeMXNetworkTransferMetric— Wi-Fi/cellular bytes up/downMXSignpostMetric— aggregated stats from youros_signpostintervalsMXLocationActivityMetric— location precision usedMXAppLaunchMetric— launch durationsMXHangDiagnostic,MXCrashDiagnostic,MXCPUExceptionDiagnostic— failure reports with symbolicated stacks
Wire this up once and forget:
import MetricKit
final class MetricsSubscriber: NSObject, MXMetricManagerSubscriber {
static let shared = MetricsSubscriber()
func start() {
MXMetricManager.shared.add(self)
}
func didReceive(_ payloads: [MXMetricPayload]) {
for payload in payloads {
let json = payload.jsonRepresentation()
// Ship it to your telemetry pipeline. Don't try to parse on-device;
// store and forward.
uploadMetricsBlob(json)
}
}
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
uploadDiagnosticBlob(payload.jsonRepresentation())
}
}
}
The key insight: MXSignpostMetric exposes your os_signpost intervals as production aggregates. If you put a signpost around "feed page load," you'll get p50/p95/p99 timings from real users on real devices on real networks. This is the single highest-ROI move you can make for production performance visibility.
6.8 The Xcode Organizer's hidden gems
Xcode → Window → Organizer. Two panes you should be looking at weekly if you have a shipped app:
- Crashes — symbolicated crash reports, grouped, with version filtering.
- Hangs — same idea, for main-thread hangs from production. Apple distributes these via TestFlight and (with user opt-in) the App Store.
- Disk Writes, Energy, Launch Time, Scrolling, Memory — aggregated MetricKit-style views over your install base, with version-over-version comparisons.
The Organizer is where you find out that your latest release regressed cold launch by 200 ms before users start tweeting about it. Make a habit.
6.9 A Profiling Checklist
When something is slow or hot, in order:
- Reproduce on a real, recent-but-not-flagship device — a base iPhone, not your Pro Max. Performance bugs hide on fast hardware.
- Open Time Profiler, run the trace, invert, hide system libraries, scope to the spike. Where's the heat?
- Cross-check Allocations for the same window. Is the heat correlated with allocation churn?
- Add
os_signpostintervals around the operation if you don't have them yet. Re-run with signposts visible. - Open the Energy Log if it's a "battery" complaint. Look for unexpected always-on patterns.
- Check
URLSessionTaskMetricsin the Network instrument. Is the network slow, or is the app slow?
When introducing a feature:
- Wrap its big units of work in signposts.
- Subscribe to
MetricKitif you haven't already. - Profile a Release build of your feature on a base-spec device before you ship.
- After ship, check the Organizer for the next 1–2 versions.
6.10 What to remember from Part 6
- Profile in Release. Profile on a base-spec device. Filter to your code, then look for hotspots.
- Signposts are the bridge between "I know what's happening at the system level" and "I know what's happening at the feature level." Put them everywhere.
- The Energy Log finds wasted radio time and stuck timers more easily than Time Profiler does.
- MetricKit + signposts + Organizer is your production observability. Wire it once and watch it forever.
- Hangs are the user-facing performance bug. Watch the Hangs Organizer like you watch crashes.
Appendix A — A Reading and Practice Plan
A topic-by-topic suggestion list to deepen any area above.
Networking & TCP/IP
- High Performance Browser Networking, Ilya Grigorik — the single best starting point. Free online.
- Apple WWDC: "Reduce networking delays for a more responsive app" (2021), "Boost performance and security with modern networking" (2020), "Discover Network.framework" (2018).
- Practice: instrument every request in one of your apps with
URLSessionTaskMetrics, log proto/reused/dns/tls/ttfb, see the distribution.
TLS
- Bulletproof TLS and PKI, Ivan Ristić — the canonical reference. Dense but worth it.
- RFC 8446 (TLS 1.3) — surprisingly readable.
- Practice: implement the SPKI-pinning delegate, verify it against your own backend, then deliberately swap the cert and watch it fail.
iOS Security APIs
- Apple's "Cryptographic Services" guide and the
CryptoKitdocumentation set. - iOS Application Security, David Thiel — a bit dated but the structural advice still stands.
- Practice: build the E2EE envelope from Part 3, verify the server can decrypt, then write a test that catches AAD mismatch (it must fail closed).
Network Extension Framework
- Apple's "Network Extension" framework documentation. Read every provider type's overview.
- WWDC: "Building a Custom Networking Protocol Extension" if you want to dig in.
- Practice: build a stub
NEPacketTunnelProviderthat does no real tunneling — just hands packets back to the system. Get the configuration round-trip working end to end.
MDM
- Apple's Device Management docs and the MDM Protocol Reference.
- AppConfig.org — read a few schema examples.
- Practice: write a
.mobileconfigthat pushes a managed config dictionary to your app via Apple Configurator. Verify your app reads it. Then write a feedback dictionary back.
Performance
- WWDC: "What's new in Instruments" any year. "Identify trends with the Power and Performance API" (MetricKit). "Eliminate animation hitches with XCTest" (2020).
- Practice: pick the slowest screen in your app, add five signposts, run Time Profiler + Allocations + Network in parallel, write up what you found.
End of manual.