HomeDocumentationiOS Under The Surface
iOS Under The Surface
14

The Sandbox, Code Signing, and the Secure Enclave

iOS App Sandbox, Code Signing, and Secure Enclave for Flutter

March 30, 2026

iOS's security model is built from three interlocking mechanisms: the sandbox (what your app can access), code signing (proof that your app is what it claims to be), and the Secure Enclave (a separate processor that protects cryptographic keys from everyone, including the kernel).

On Android, security is layered — UID isolation, SELinux, seccomp, runtime permissions — with each layer independently enforceable (Android series, Post 9). iOS has a different philosophy: the layers are tightly coupled. The sandbox's restrictions are derived from your app's entitlements, which are embedded in your code signature, which is verified by the kernel before any code executes. Pull one thread and the whole model unravels — which is why Apple goes to extraordinary lengths to ensure none of the threads can be pulled.

The app sandbox

Every third-party iOS app runs in a sandbox — a restricted environment that limits what the app can access on the file system, which system services it can use, and how it can communicate with other processes.

The sandbox is stricter than Android's:

File system. Your app can only access its own container directory: /private/var/containers/Bundle/Application/<UUID>/ (the app bundle, read-only) and /private/var/mobile/Containers/Data/Application/<UUID>/ (the data container, read-write). Within the data container, you have Documents/, Library/, tmp/, and Caches/. Your app cannot read any other app's container, any system directory outside the allowed set, or the general file system.

Android's equivalent is similar in principle (UID-based file isolation), but Android has more escape hatches: the READ_EXTERNAL_STORAGE permission (deprecated but still functional on older APIs), the Storage Access Framework, content providers. iOS has no equivalent — apps cannot access each other's files, period. The only way to share data between apps is through app groups (a shared container for apps with the same team ID), the clipboard, or explicit sharing through share sheets.

Network. The sandbox doesn't restrict networking broadly — your app can make any outgoing connection. But App Transport Security (ATS) enforces TLS for HTTP connections by default: plaintext HTTP is blocked unless you add an explicit exception in Info.plist. This is a code-level restriction, not a runtime permission.

Hardware. Access to cameras, microphone, Bluetooth, and location requires both an entitlement (compiled into the app) and a runtime permission (granted by the user). Without the entitlement, the API doesn't exist for your app. Without the runtime permission, the API returns an error.

IPC. Your app can communicate with system services via XPC (Post 6), but it cannot create arbitrary Mach port connections to other apps. The only inter-app communication channels are URL schemes, universal links, the clipboard, and app extensions. This is far more restricted than Android, where apps can communicate via content providers, broadcasts, bound services, and shared file storage.

The sandbox is enforced by the kernel. The sandbox kernel extension (part of XNU) intercepts syscalls and checks them against the app's sandbox profile. A file open() for a path outside the container is blocked before the file system is even consulted. This is similar to SELinux on Android — mandatory access control enforced at the kernel level, not bypassable by the app.

Entitlements: what your app is allowed to do

Entitlements are key-value pairs embedded in your app's code signature that declare capabilities. They're the iOS equivalent of Android manifest permissions, but with a critical difference: they're cryptographically signed and verified by the kernel.

Common entitlements for Flutter apps:

xml
<!-- YourApp.entitlements -->
<dict>
    <!-- App sandbox (required for all iOS apps) -->
    <key>com.apple.security.app-sandbox</key>
    <true/>

    <!-- Push notifications -->
    <key>aps-environment</key>
    <string>production</string>

    <!-- Keychain access -->
    <key>keychain-access-groups</key>
    <array>
        <string>$(AppIdentifierPrefix)com.yourcompany.yourapp</string>
    </array>

    <!-- App groups (shared data container) -->
    <key>com.apple.security.application-groups</key>
    <array>
        <string>group.com.yourcompany.yourapp</string>
    </array>

    <!-- Associated domains (universal links) -->
    <key>com.apple.developer.associated-domains</key>
    <array>
        <string>applinks:yourapp.com</string>
    </array>
</dict>

The entitlements file is compiled into the app binary and signed with the code signature. The kernel verifies entitlements at process creation — before your code runs, the kernel checks that the binary's code signature is valid and that the claimed entitlements are authorised by the provisioning profile.

This is a tighter coupling than Android's model. On Android, an app declares permissions in its manifest and the system grants them at install or runtime. The manifest is part of the APK but the permissions are managed separately by PackageManagerService. On iOS, the entitlements are part of the cryptographic identity of the app — changing an entitlement requires re-signing the binary.

Code signing: identity and integrity

Every executable on iOS must be code-signed. This is enforced by the kernel — an unsigned binary cannot be loaded, and a binary whose signature doesn't match its contents cannot execute.

Code signing provides two guarantees:

Identity. The signature ties the binary to a specific developer (via their Apple Developer certificate). When the system checks "was this app built by a legitimate developer?", it verifies the signature chain: the app's signature was made with a certificate issued by Apple's Certificate Authority. If the certificate is revoked (the developer account is terminated), the signature is invalid.

Integrity. The signature covers the binary's contents. Every page of executable code is hashed, and the hashes are included in the signature. When the kernel loads a code page, it verifies the page's hash against the signed hash. If the page has been modified (by an attacker, by corruption, or by a bug), the hash doesn't match, and the kernel refuses to execute it.

This per-page verification is continuous, not just at launch. Every time a code page is loaded from disk (after being evicted from RAM — common during memory pressure, Post 4), the kernel re-verifies its hash. This prevents attacks where an attacker modifies the on-disk binary after the initial verification.

For Flutter apps, code signing covers:

  • The main app binary (your thin AppDelegate + Flutter embedding)
  • Flutter.framework (the Flutter engine)
  • App.framework (your compiled Dart code)
  • Plugin frameworks (each embedded framework is separately signed)

All frameworks must be signed with the same team identity. A framework signed by a different developer can't be embedded in your app — the system rejects it at install time.

The JIT prohibition

Code signing has a profound consequence: no JIT compilation (without a special entitlement).

JIT compilation works by writing machine code to memory at runtime and then executing it. This requires pages that are both writable and executable (W+X). iOS's code signing policy blocks this — a page can be writable or executable, but not both. The kernel enforces this through the MAP_JIT restriction.

Only Apple's own WebKit engine has the com.apple.security.cs.allow-jit entitlement, allowing it to JIT-compile JavaScript. Third-party apps (including Flutter) cannot get this entitlement.

This is why:

  • Flutter uses AOT compilation on iOS, even for profile builds
  • The Dart VM cannot JIT-compile on iOS devices (it can on the simulator, which runs on macOS with relaxed restrictions)
  • Hot reload works via a different mechanism on iOS — it doesn't JIT-compile Dart code, it uses the Dart kernel format and interprets it (which is why debug builds are slower)

The performance implication: Dart's AOT-compiled code runs at native speed, but it can't adapt to runtime profiles the way a JIT compiler can. In practice, this matters less than it sounds — Dart's AOT compiler produces efficient code, and the consistency of AOT (no JIT warm-up, no compilation pauses) is a net positive for frame time predictability.

The Secure Enclave

The Secure Enclave Processor (SEP) is a physically separate chip (or a walled-off section of the main chip) with its own processor, memory, and cryptographic engine. It runs its own OS (sepOS), has its own boot chain, and is isolated from the main processor at the hardware level.

The main application processor — where XNU, your Flutter app, and everything else runs — cannot read the Secure Enclave's memory. The Enclave communicates with the main processor only through a hardware mailbox — a restricted communication channel that passes requests and responses but never exposes the Enclave's internal state.

What the Secure Enclave protects:

Biometric data. Face ID and Touch ID templates are stored in the Secure Enclave's memory. The main processor never sees them. When you authenticate with Face ID, the Secure Enclave performs the comparison internally and returns a yes/no result.

Keychain encryption keys. The Secure Enclave generates and stores the encryption keys used to protect Keychain items. When flutter_secure_storage writes a value to the Keychain, the value is encrypted with a key that exists only inside the Secure Enclave. The main processor never sees the raw encryption key.

Device passcode verification. The passcode hash is stored in the Secure Enclave with hardware-enforced rate limiting. Even if an attacker has full control of the main processor, they cannot brute-force the passcode faster than the Enclave allows (incrementing delays after failed attempts, up to hours between attempts).

Secure boot chain. The Secure Enclave verifies each stage of the boot process, from the Boot ROM through the kernel. If any stage has been tampered with, the Enclave can refuse to release keys needed for device operation.

For Flutter developers, the Secure Enclave is relevant in two ways:

Keychain items with `kSecAttrAccessibleWhenUnlocked`. These items can only be decrypted when the device is unlocked (the Secure Enclave releases the decryption key only after successful passcode/biometric authentication). flutter_secure_storage uses this access level by default. A stolen device that's locked cannot decrypt your stored tokens — the encryption key is locked inside the Secure Enclave, which won't release it without the passcode or biometric.

Biometric-protected Keychain items. Items with kSecAccessControlBiometryCurrentSet require Face ID or Touch ID authentication for each access. The Secure Enclave performs the biometric check and only releases the decryption key on success. The local_auth plugin uses this for biometric gates (as covered in the security series).

App Attest: runtime integrity verification

Starting with iOS 14, Apple provides App Attest — a service that lets your backend verify that requests genuinely come from your unmodified app running on a real Apple device.

App Attest uses the Secure Enclave to generate a device-specific key pair. The private key never leaves the Enclave. Your app can use this key to sign assertions (statements like "this request was made by the real app"), and your backend can verify these assertions against Apple's attestation service.

This protects against:

  • Modified apps — a jailbroken device running a modified version of your app can't produce valid attestation (the key pair is tied to the specific app binary's code signature)
  • Replay attacks — each assertion includes a server-generated challenge
  • Emulators/simulators — the Secure Enclave only exists on real hardware

For Flutter, the app_attest or device_check plugins wrap this API. The practical use case: protecting premium features, validating in-app purchases on your server, or preventing API abuse. App Attest doesn't prevent all tampering (a sufficiently motivated attacker can bypass it), but it raises the bar significantly.

Provisioning profiles: the trust chain

The connection between your development machine, Apple's servers, and the device is managed by provisioning profiles — files that declare which apps can run on which devices, with which entitlements, signed by which certificates.

A provisioning profile contains:

  • The app ID (bundle identifier)
  • The team ID (your developer account)
  • Allowed entitlements
  • Allowed device UDIDs (for development profiles) or "any device" (for distribution profiles)
  • An expiration date
  • Apple's signature

When your Flutter app is installed on a device, the system verifies:

  1. The code signature is valid (signed by a certificate Apple issued)
  2. A provisioning profile exists that authorises this team ID, this app ID, and these entitlements
  3. Apple's signature on the provisioning profile is valid
  4. The profile hasn't expired

If any check fails, the app doesn't launch. This is why "Untrusted Developer" errors occur on sideloaded apps — the device hasn't verified the developer's certificate with Apple's servers.

For App Store distribution, the verification is simpler: Apple re-signs the app with an Apple distribution certificate and includes a distribution provisioning profile. The device trusts Apple's signature implicitly.

The sandbox vs Android's sandbox

| Aspect | Android | iOS | |--------|---------|-----| | File isolation | UID-based, kernel-enforced | Container-based, sandbox kernel extension | | Inter-app communication | Content providers, broadcasts, bound services | URL schemes, universal links, share sheets only | | Code integrity | APK signing (verified at install) | Code signing (verified per-page, continuously) | | JIT compilation | Allowed (debug), AOT (release) | Blocked (no W+X pages for third-party apps) | | Hardware security | Android Keystore (TEE or StrongBox) | Secure Enclave | | Entitlements | Manifest permissions (runtime-grantable) | Signed entitlements (build-time, kernel-verified) | | Mandatory access control | SELinux | Sandbox kernel extension | | Runtime attestation | Play Integrity API | App Attest |

iOS's sandbox is more restrictive in most dimensions — fewer inter-app communication channels, stricter file access, continuous code verification. The tradeoff: less flexibility for developers, but a smaller attack surface.

Practical implications for Flutter

Entitlements must be correct at build time. Missing an entitlement means the API fails silently or with a cryptic error. Adding push notification support? Add the aps-environment entitlement. Using Keychain sharing? Add the keychain-access-groups entitlement. These go in your Xcode project's capabilities tab, which modifies the .entitlements file.

Code signing errors are build-time, not runtime. If your provisioning profile doesn't match your entitlements, or your certificate is expired, or your bundle ID is wrong, the build fails (or the app fails to install). These errors are frustrating but at least they're caught before the user sees them.

No dynamic code loading. You can't download and execute Dart code at runtime (because JIT is blocked, and loading new native code would violate the code signature). Flutter's deferred components (Android's deferred loading) don't work the same way on iOS — all Dart code must be compiled and included in the app bundle at build time.

Keychain is stronger than Android's equivalent. iOS's Keychain, backed by the Secure Enclave, provides hardware-level encryption key protection. Android's Keystore with StrongBox is equivalent, but StrongBox is only available on some devices. iOS's Secure Enclave is on every device since iPhone 5s (2013). When flutter_secure_storage stores a value on iOS, it genuinely has hardware-backed encryption.

The next post covers networking: how your Flutter HTTP request travels from Dart through the iOS network stack to the wire.

This is Post 8 of the iOS Under the Surface series. Previous: Core Animation and Metal: The Rendering Pipeline. Next: Networking: From Dart to the Wire.

Related Topics

ios app sandbox flutterios code signingsecure enclave flutterios entitlementsios app securityflutter ios keychainios code signing explained flutter

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.