Security
14

Platform Security: iOS Hardening for Flutter Apps

iOS Platform Security for Flutter Developers

March 25, 2026

Part 9 of the Flutter Security Beyond the Basics series.

Everything covered so far in this series — secure storage, token management, certificate pinning, biometrics, obfuscation, screenshot protection, root and jailbreak detection — has been implemented at the Dart level or through Flutter plugins. Your code calls a method, a plugin bridges to native, and the behaviour takes effect. You are working above the operating system.

Platform security is the layer underneath: OS-level configurations, entitlements, and policies that apply before your Dart code runs a single line. iOS has strong opinions about security, and most of the defaults are correct. The danger is not that iOS is insecure out of the box. The danger is that developers disable security features to solve connectivity problems during development and never re-enable them — or never understood what they turned off.

This post focuses exclusively on iOS. Android deserves its own treatment. iOS is worth isolating because its security model is unusually opinionated — Apple makes many decisions for you, and the ones it leaves to you are the ones that matter most.

---

App Transport Security

App Transport Security (ATS) enforces encrypted network connections. Since iOS 9, all HTTP connections must use HTTPS with TLS 1.2 or later and cipher suites supporting forward secrecy. This is not a recommendation — the OS blocks plain HTTP requests outright.

The rationale: unencrypted HTTP traffic can be read and modified by anyone on the same network. Coffee shop Wi-Fi, compromised routers, malicious access points — all allow trivial interception. By mandating HTTPS at the OS level, Apple removed an entire category of vulnerability from every app on the platform.

The dangerous override

During development, your Flutter app cannot connect to a local server at http://192.168.1.50:3000 with no TLS certificate. A quick search produces dozens of answers with the same fix:

xml
<!-- DO NOT DO THIS -->
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>

This disables ATS entirely. Every connection — your API, third-party SDKs, analytics, ad networks — can now use unencrypted HTTP. It is the equivalent of removing the lock from your front door because you lost the key. The problem was one specific door; the solution removed security from all of them.

It appears in beginner tutorials as step one of "getting your app to work," and ships to production because nobody circled back to fix it. Apple's App Store review team has been known to reject apps with NSAllowsArbitraryLoads set to true without justification.

The correct approach

Use NSExceptionDomains to scope the exception to one server:

xml
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSExceptionDomains</key>
    <dict>
        <key>192.168.1.50</key>
        <dict>
            <key>NSExceptionAllowsInsecureHTTPLoads</key>
            <true/>
            <key>NSIncludesSubdomains</key>
            <false/>
        </dict>
    </dict>
</dict>

Plain HTTP is allowed to that single IP. Everything else still requires HTTPS with TLS 1.2+ and forward secrecy.

For production, the NSAppTransportSecurity dictionary should either be absent (the defaults are correct) or contain only justified domain-specific exceptions. In a Flutter project, the file lives at ios/Runner/Info.plist. If you use build flavours, maintain separate Info.plist files — one for debug with local server exceptions, one for release with none.

---

App Attest

Post 8 covered jailbreak detection and noted that client-side checks are inherently bypassable — an attacker with Frida hooks the boolean check and moves on. App Attest is Apple's server-side answer: your server can verify that a request came from a genuine, unmodified copy of your app on real Apple hardware, through Apple's infrastructure rather than your app's code.

How it works

  1. Key generation. Your app asks DCAppAttestService to generate a key pair. The private key is created inside the Secure Enclave and never leaves it.
  1. Attestation. Your app sends the key identifier to Apple along with a hash of a server-provided challenge. Apple verifies the key was generated on genuine hardware, in your specific app, and returns a signed attestation object.
  1. Server verification. Your server verifies the attestation against Apple's root certificate — confirming the key is real, the device is real, and the app is yours.
  1. Assertion. Subsequent requests are signed with the attested key. Your server verifies each signature, confident the request originated from a legitimate app instance.

A jailbroken device can fake client-side boolean checks. It cannot fake Apple's attestation — the Secure Enclave remains isolated even on a jailbroken device, and Apple's signing infrastructure is outside the attacker's control.

When to use it

App Attest adds complexity: server-side verification, handling unsupported devices, network failures during attestation, key rotation. Use it when the cost of request forgery is real — financial transactions, premium content, operations with monetary or legal consequences.

The key class is DCAppAttestService:

swift
import DeviceCheck

let service = DCAppAttestService.shared

guard service.isSupported else {
    // Fall back to alternative verification
    return
}

service.generateKey { keyId, error in
    guard let keyId = keyId else { return }
    // Store keyId, request a challenge from your server,
    // then call service.attestKey(keyId, clientDataHash: hash)
}

From Flutter, call this through a platform channel. The Dart side orchestrates — requesting a server challenge, passing it to native attestation, returning the result. The cryptographic operations stay on the native side.

---

Keychain configuration in depth

Post 1 covered flutter_secure_storage and noted that on iOS it uses the Keychain. But the Keychain has configuration options that significantly affect security, and the defaults may not suit your needs.

Data protection classes

Every Keychain item has an accessibility attribute determining when it can be read:

  • `kSecAttrAccessibleAfterFirstUnlock` — Available after the device has been unlocked once since boot. Background processes can access it.
  • `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` — Same, but excluded from iCloud backups and device migration.
  • `kSecAttrAccessibleWhenUnlocked` — Only available while the device is currently unlocked.
  • `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` — Same, plus excluded from backups and migration.
  • `kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly` — Most restrictive. Requires a passcode and current unlock. If the user removes their passcode, the item becomes permanently inaccessible.

flutter_secure_storage defaults to whenUnlocked, which is reasonable — but if your app refreshes tokens in the background (background fetch, push notification handlers), the token is inaccessible when the screen is locked. The background operation fails silently. For such tokens, afterFirstUnlockThisDeviceOnly is typically correct:

dart
const storage = FlutterSecureStorage(
  iOptions: IOSOptions(
    accessibility: KeychainAccessibility.first_unlock_this_device,
  ),
);

The "ThisDeviceOnly" distinction

ThisDeviceOnly variants prevent items from syncing via iCloud Keychain or appearing in encrypted backups. For authentication tokens — bound to a specific device session — this is almost always correct. If a user restores a backup to a new phone, old tokens should not migrate; the user should re-authenticate. For user-saved passwords ("remember me" credentials), migration might be desirable. Choose based on what the data represents.

Keychain persistence across uninstalls

Keychain items survive app uninstallation. Uninstall and reinstall, and the old token is still there. This causes subtle bugs: the user expects a clean state, your app reads a stale token, the server rejects it, and the user sees a cryptic error instead of a login screen.

The workaround: store a flag in UserDefaults (which is deleted on uninstall). On launch, if the flag is missing, clear Keychain items:

dart
Future<void> handleFreshInstall() async {
  final prefs = await SharedPreferences.getInstance();
  final hasRunBefore = prefs.getBool('has_run_before') ?? false;

  if (!hasRunBefore) {
    const storage = FlutterSecureStorage();
    await storage.deleteAll();
    await prefs.setBool('has_run_before', true);
  }
}

Call this early in initialisation, before any code reads from secure storage.

Access groups

If your app has extensions (widgets, share extensions, notification service extensions) or companion apps, Keychain access groups let separate binaries share items. Configure in your entitlements with matching group identifiers across all targets that need shared access.

---

Entitlements

Entitlements are declarations signed into your app binary that tell iOS what capabilities the app may use. The OS verifies them at runtime.

Security-relevant entitlements:

  • Keychain access groups (keychain-access-groups): Which Keychain groups your app can access. Without it, only the default group is available.
  • App Attest (com.apple.developer.devicecheck.appattest-environment): Required for App Attest. Value is development or production — the wrong value causes silent failure.
  • Associated domains (com.apple.developer.associated-domains): Links web domains to your app for Universal Links. Unlike custom URL schemes (which any app can register), Universal Links are verified against your server's apple-app-site-association file and cannot be hijacked.
  • Hardened Runtime (macOS Flutter apps): Prevents code injection, dylib hijacking, and debugging in production. Required for notarisation.

Entitlements live in the .entitlements file under ios/Runner/. Xcode manages them when you add capabilities, but understand what they contain — a missing or misconfigured entitlement produces silent errors, App Store rejections, or features that work in debug but break in release.

---

Info.plist hardening

Beyond ATS, several Info.plist keys affect security.

Usage description strings

Every permission — camera, microphone, location, photo library — needs a usage description. This is the text in the system permission dialog.

xml
<key>NSCameraUsageDescription</key>
<string>Camera access is used to scan QR codes for two-factor authentication setup.</string>

Missing descriptions mean App Store rejection. Vague descriptions erode trust. Only request permissions your app actually uses — every declared permission is attack surface. If you removed a feature that used the camera, remove the camera permission too.

LSApplicationQueriesSchemes

Declares which URL schemes your app can probe via canOpenURL(). Before iOS 9, any app could detect which other apps were installed — fingerprinting the device. Now you must declare each scheme explicitly. Only list what you genuinely need.

xml
<key>LSApplicationQueriesSchemes</key>
<array>
    <string>comgooglemaps</string>
</array>

UIRequiresFullScreen

Prevents Split View and Slide Over on iPad. In Split View, an adjacent app could observe your content. This is an extreme measure that degrades the iPad experience — rarely appropriate, but worth knowing about for regulated apps handling medical or financial data.

---

iCloud and backup security

Your app's Documents and Library directories (except Library/Caches) are backed up to iCloud by default. This includes SQLite databases, downloaded files, and cached user data.

For sensitive files that should not leave the device, either store them in Library/Caches (automatically excluded, but may be purged) or explicitly exclude them:

swift
var fileURL = URL(fileURLWithPath: filePath)
var resourceValues = URLResourceValues()
resourceValues.isExcludedFromBackup = true
try fileURL.setResourceValues(resourceValues)

Call this from Flutter through a platform channel. The path_provider package maps to standard directories:

  • getApplicationDocumentsDirectory() — backed up by default
  • getApplicationSupportDirectory() — backed up by default
  • getTemporaryDirectory() — not backed up, may be purged

For truly sensitive persistent data, store it in the Keychain with a ThisDeviceOnly protection class rather than as a file.

---

App Store review and security

iOS security extends to the distribution model. App Store review catches some security violations before they reach users.

Automated checks scan for private API usage, missing usage descriptions, known malware, and unjustified ATS overrides. Human reviewers check whether permissions are proportional to functionality and whether data handling matches declared privacy labels.

Privacy labels

Every App Store listing must declare data collection practices: what data is collected, whether it is linked to the user's identity, and whether it is used for tracking. Categories span contact information, financial data, location, browsing history, identifiers, and more.

These are self-reported, but false declarations carry consequences — App Store removal and developer account suspension. Treat them as accurate documentation of your data practices.

Apps handling health, financial, or children's data face stricter scrutiny. For these categories, the platform security described in this post is not optional hardening — it is baseline compliance.

---

Bringing it together

iOS platform security sits beneath everything else. Secure storage, certificate pinning, and biometric gates all depend on the platform behaving correctly. ATS ensures encrypted traffic. Keychain classes determine when secrets are accessible. Entitlements define what your app may do. Backup policies determine where data ends up.

The practical summary:

  • Remove `NSAllowsArbitraryLoads` from production. Use domain-specific exceptions for development only.
  • Choose Keychain protection classes deliberately. Background tokens need afterFirstUnlockThisDeviceOnly. Active-session tokens need whenUnlockedThisDeviceOnly.
  • Handle Keychain persistence across reinstalls with the UserDefaults flag pattern.
  • Evaluate App Attest for operations where request authenticity has financial or legal consequences.
  • Audit entitlements and permissions. Remove unused capabilities.
  • Exclude sensitive files from backup.
  • Fill in privacy labels honestly.

Most iOS security defaults are correct. Your job is to not weaken them accidentally, to understand the options left to you, and to make deliberate choices about the small number of settings that depend on your app's specific requirements.

Related Topics

flutter ios securityapp transport security flutterapp attest flutterios keychain flutterinfo.plist securityios entitlements flutterflutter secure storage keychainios backup securityflutter ats configurationios hardened runtime flutter

Ready to build your app?

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