Securely Storing Data on iOS

Our mobile devices and the apps that run on them have a unique insight into our lives. We use them for communication, entertainment, shopping, photography, navigation, and myriad other purposes. Consequently, apps have access to a great deal of information about our preferences, our contacts, where we go, what we buy, and who we are. As app developers, we need to be cognizant of the information being handled by our apps and to safeguard it accordingly.

The protection of sensitive information is a broad topic. This article will focus specifically on data storage on the user's device. Almost all apps require some data to be stored in a manner that will persist across app launches. However, simply storing data in the clear on the file system or in UserDefaults does not provide adequate security for sensitive user information.

This article will show you how to use the safeguards built into iOS to protect user data. We will present how to encrypt data stored on the file system, how to use the keychain to store sensitive data like usernames and passwords, and how to add biometric authentication to keychain items for added security.

File System

iOS provides data protection facilities to secure an app's files and to prevent unauthorized access to them. This is all handled automatically by the operating system — the system encrypts and decrypts files on the fly without requiring any special code in the app.

Each file has an associated content protection attribute that determines when the file is encrypted and when it can be accessed. There are four content protection modes available:

  • Complete The file is stored in an encrypted format and may be read from or written to only while the device is unlocked. At all other times, attempts to read and write the file will fail. This is the most restrictive file protection available.

  • Complete unless open You can open existing files only when the device is unlocked. If a file is opened while the device is unlocked, you may continue to access that file after the device is locked. New files may be created at any time, regardless of whether the device is locked.

  • Complete until first user authentication This is the default protection mode. The file is inaccessible until the first time the user unlocks the device. After the user first unlocks the device, the file remains accessible until the device is shut down or rebooted.

  • No protection The file is not stored in an encrypted format and may be accessed at any time.

To encrypt a file at creation time, pass a content protection attribute under the options parameter when calling Data.write(to:options:) . To change the content protection of an existing file, you can use the NSURL.setResourceValue(_:forKey:) function, passing a URLFileProtection value for the .fileProtectionKey key.

You can read from a protected file the same way you would read from any other file. No additional parameters are required to handle the data protection. However, if the file is not currently available due to data protection (for example, if the file uses the complete file protection mode and the device is currently locked) the operation will fail.

Your app delegate will be notified when files with complete file protection become unavailable and available. You can use the applicationProtectedDataWillBecomeUnavailable(_:) and applicationProtectedDataDidBecomeAvailable(_:) delegate functions to close and reopen those files as necessary.

When choosing a content protection mode, it is important to keep in mind when a file is available and when your app may need to access it. Apple recommends the following:

Assign the complete protection level to files that your app accesses only when it is in the foreground. If your app supports background capabilities, such as handling location updates, assign a different protection level for files that you might access while in the background. For example, a fitness app might use the complete unless open protection level on a file that it uses to log location events in the background.

Files containing personal information about the user, or files created directly by the user, always warrant the strongest level of protection. Assign the complete protection level to user data files and manage access to those files using the app delegate methods. The app delegate methods give you time to close the files before they become inaccessible to your app.

Excluding files from backup

When considering secure storage options for your app's data, you should also take into consideration iOS backups. When a user backs up his or her device, everything in the app's home directory is backed up, with the exception of the app bundle itself, the caches directory, and temp directory. It is possible for the app to programmatically exclude particular files from these backups.

When creating files containing sensitive data, you should consider whether to include them in the user's backups. If it is possible to recreate the file or re-download the file from your app's backend, consider excluding it from backup.

You can exclude a file from backup using the NSURL.setResourceValue(_:forKey:) API as follows:

func excludeFromBackup(fileAt url: URL) throws {
    try (url as NSURL).setResourceValue(NSNumber(booleanLiteral: true),
                                        forKey: .isExcludedFromBackupKey)
}

Apple recommends setting this property each time you save the file because some common file operations reset the property to false.

Keychain

The iOS keychain is ideal for storing small secrets like account information and access tokens. The keychain is an encrypted database managed by the system. Apps can create, read, update, and delete keychain items using a query-based API provided in the Security framework. The API is C-based, so it is not as easy to interact with as other Objective-C or Swift APIs. That said, it is also well documented by Apple and many code examples are provided to help.

Accessing the keychain

All access to the keychain is done via queries. You build a dictionary of key-value pairs containing the parameters of the query and pass it to the API functions to create, read, update, and delete keychain items.

The keychain supports multiple classes of item: kSecClassGenericPassword, kSecClassInternetPassword, kSecClassCertificate, kSecClassKey, and kSecClassIdentity. Each item class supports a different set of item attributes. Follow the links to see Apple's documentation on each class and its associated attributes.

We will look at the kSecClassInternetPassword as that includes fields for account (i.e., username), server, and password.

To add an item to the keychain, you call the SecItemAdd(_:_:) function. To retrieve an item from the keychain, you call the SecItemCopyMatching(_:_:) function. For example:

enum KeychainError: Error {
    case encodingError
    case notFound
    case unexpectedPasswordData
    case unhandledError(OSStatus)
}

func savePassword(forServer server: String,
                  account: String,
                  password: String) throws {

    guard let passwordData = password.data(using: .utf8) else {
        throw KeychainError.encodingError
    }

    let query: [String: Any] = [
        kSecClass as String: kSecClassInternetPassword,
        kSecAttrAccount as String: account,
        kSecAttrServer as String: server,
        kSecValueData as String: passwordData
    ]

    let status = SecItemAdd(query as CFDictionary, nil)
    guard status == errSecSuccess else {
        throw KeychainError.unhandledError(status)
    }
}

func loadPassword(forServer server: String,
                  account: String) throws -> String {

    let query: [String: Any] = [
        kSecClass as String: kSecClassInternetPassword,
        kSecAttrServer as String: kSecMatchLimitOne,
        kSecReturnAttributes as String: true,
        kSecReturnData as String: true
    ]

    var item: CFTypeRef?
    let status = SecItemCopyMatching(query as CFDictionary, &item)

    guard status != errSecItemNotFound else {
        throw KeychainError.notFound
    }

    guard status == errSecSuccess else {
        throw KeychainError.unhandledError(status)
    }

    guard
        let existingItem = item as? [String: Any],
        let passwordData = existingItem[kSecValueData as String] as? Data,
        let password = String(data: passwordData, encoding: .utf8)
    else {
        throw KeychainError.unexpectedPasswordData
    }

    return password
}

Keychain item accessibility

Similar to the file system content protection attributes discussed above, you can configure the accessibility of a keychain item to define when it can be accessed. This is done by including one of the following accessibility values in the query dictionary under the kSecAttrAccessible key. In order of decreasing restrictiveness:

Several of the accessibility values above have variants with the suffix ThisDeviceOnly, indicating that the associated item should not be restored from backup when migrating to a new device.

User presence and biometrics

Beyond the protection provided by a device passcode, you can configure keychain items to require biometric authentication. This is specified by including an access control instance in the query dictionary when creating a keychain item.

An access control instance is created using the SecAccessControlCreateWithFlags function, which takes an optional argument to specify the accessibility as discussed above (otherwise the accessibility can be specified directly in the query dictionary). It also takes a set of SecAccessControlCreateFlags allowing you to restrict access, requiring one of the following forms of authentication before you can read the item:

  • devicePasscode requires the user to enter the device passcode.
  • biometryAny requires Touch ID or Face ID.
  • biometryCurrentSet also requires Touch ID or Face ID. Additionally, this item is invalidated if the user's biometric enrollment is changed (for example, fingers added or removed from Touch ID).
  • userPresence requires either biometric authentication or passcode entry.
  • watch requires a nearby, paired Apple watch running watchOS 6 or later.

For example, to create an access control instance requiring biometrics:

func createAccessControlRequiringBiometrics() throws -> SecAccessControl? {
    var error: Unmanaged<CFError>?
    let access =
        SecAccessControlCreateWithFlags(nil, 
                                        kSecAttrAccessibleWhenUnlocked,
                                        .userPresence,
                                        &error)

    if let error = error?.takeUnretainedValue() {
        throw error
    }

    return access
}

You can then include the resulting SecAccessControl instance in the query dictionary passed to SecItemAdd(_:_:) under the kSecAttrAccessControl key.

Keychain persistence across installs

It is worth noting that the keychain persists across app installs. This can lead to otherwise unexpected behavior. If you cache the user's username and password to the keychain, users will likely expect that data to be deleted when the app is deleted. Users, including product owners and QA engineers, often find it surprising that data remains available after deleting and reinstalling the app.

While the keychain persists across installs, the app's UserDefaults do not. You can take advantage of this fact by storing a flag in UserDefaults indicating whether the app has run before. If that flag comes back as false, you can delete any keychain items from a previous installation, if they exist.

class AppDelegate: UIResponder, UIApplicationDelegate {

    private let hasRunBeforeKey = "hasRunBefore"

    private var hasRunBefore: Bool {
        get {
            return UserDefaults.standard.bool(forKey: hasRunBeforeKey)
        }

        set {
            UserDefaults.standard.setValue(newValue, forKey: hasRunBeforeKey)
        }
    }

    ...

    func application(_ application: UIApplication, 
         didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        if !hasRunBefore {
            // TODO: Delete any keychain items
            hasRunBefore = true
        }

        ...

Conclusion

We have seen how to protect user data on the file system and using the keychain. We have also seen the different data protection options provided by iOS that allow you to restrict when protected user data can be accessed.

When it comes time to implement this, it is generally a good idea to consider the most restrictive protection options possible while still allowing your app to properly function. Specifying too restrictive options can lead to some pretty hard to track down bugs. For example, if your app needs to access the protected data while running in the background, it is important that the protection level does not require the device to be unlocked.

This article has focused solely on data at rest on your device. In a future article, we will look at how to protect your data in transit between your device and a server.

References