When an app touches sensitive data, shipping it to the App Store is only half the job. The other half is assuming a motivated attacker will pull it apart with a jailbroken phone, a runtime instrumentation toolkit, and an intercepting proxy. On one of the government platforms I lead, that assumption drove a layered defense. None of these layers is unbreakable on its own. Together, they raise the cost of an attack enough to send most people looking for an easier target.
Layer one: certificate pinning
The first thing an attacker reaches for is a machine-in-the-middle proxy: install a trusted root CA on the device, route traffic through Charles or mitmproxy, and read every request in plaintext. Certificate pinning defeats the naive version of this by refusing to trust any certificate except the one you shipped.
I pin against the public key rather than the full certificate, so routine certificate rotation does not require an app update as long as the key pair is preserved. The check lives in the URLSession delegate's authentication challenge. Note that the standard chain evaluation still runs first: pinning narrows trust, it does not replace it.
func urlSession(_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition,
URLCredential?) -> Void) {
guard let trust = challenge.protectionSpace.serverTrust,
SecTrustEvaluateWithError(trust, nil), // normal chain validation
let key = SecTrustCopyKey(trust), // leaf public key
pinnedKeys.contains(sha256(key)) else { // SPKI hash pin check
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
completionHandler(.useCredential, URLCredential(trust: trust))
}
Two things matter in practice. Pin more than one key so you have a backup ready before you need it, and never disable pinning with a remote flag: the moment that flag exists, it becomes the single thing an attacker wants to flip.
Layer two: detecting the compromised environment
Pinning assumes the OS is honest. On a jailbroken device it is not. So the next layer asks a blunter question: are we even running in a trustworthy environment? Jailbreak detection is a collection of cheap signals, none decisive alone:
- Presence of common jailbreak paths and package managers
- Ability to write outside the app sandbox
- Suspicious dynamic libraries loaded into the process
- Whether a fork or system call succeeds when it should be blocked
The mistake I see most often is treating this as a boolean gate: if jailbroken { exit() }. That is trivial to bypass, because you have handed the attacker one branch to patch. Instead I let the signals feed into how the app behaves, and I spread the checks across the codebase so there is no single chokepoint to neutralize.
Layer three: runtime instrumentation and Frida
Frida is the tool that keeps security engineers honest. It injects a JavaScript engine into your running process and lets an attacker hook any function, rewrite return values, and watch arguments in real time. It can hook your pinning check and force it to return success, which is exactly why pinning cannot be your only defense.
You cannot make Frida impossible, but you can make its default footprint noisy. Frida's gadget listens on a well-known port, injects threads with recognizable names, and leaves traces in the list of loaded libraries. Scanning for those artifacts catches the lazy attempt. The determined attacker will rename and repackage, so I treat instrumentation detection as one input among many rather than a wall.
The philosophy: defense in depth, no single gate
The theme across all three layers is the same. A single check is a single point of failure, and reverse engineers are very good at finding single points of failure. The goal is not an impenetrable app, which does not exist on a device the user physically controls. The goal is to make the effort disproportionate to the reward, and to make sure that when one layer falls, the attacker still has three more to go.
If you are hardening a similar app and want to compare notes, my inbox is open.