developers

Security Best Practices in iOS

Take a look at techniques that you can use right now to make your iOS apps more secure.

Feb 1, 202215 min read

TL;DR: Nowadays, we use our smartphones to do almost everything, from sending a simple email to making a wire transfer. Those tasks are performed over the internet, which means that you're exposed to possible security attacks.

You need to understand that security risks are always going to exist, and you can never make your software 100% secure. What you can do is mitigate those risks and reduce them as much as possible.

As a mobile developer, you should try to make your application as secure as possible. Imagine that you're building an application for a bank institution. What happens to your client's reputation if a security breach occurs? And what about your client's customers? Imagine someone using a preventable security leak to steal their money.

Let's go over some techniques that you could start applying right now to make your mobile applications a little more secure.

What Should You Pay Attention To?

As a developer, there are four major subjects that you must check when you're developing an iOS application:

  1. Data storage
  2. Data communications channels
  3. Jailbroken devices
  4. Development techniques

I’ll cover each of these below.

Data storage

Depending on the information you're storing, you'll need to use different mechanisms to increase your application's security level.

App files

If you need to save files in your app's directory, you should use the built-in Data Protection API feature. This feature allows you to specify a level of protection for a file. If the user sets and uses a device passcode, Data Protection will be automatically enabled.

There are four levels of Data Protection available:

  • No protection. The file is always accessible.
  • Complete until first user authentication. This is the default value. The file will remain encrypted until the user unlocks the device for the first time. After that, the file will be accessible until the device shuts down or reboots.
  • Complete unless open. The file will remain encrypted until the application opens the file, and the application can open files only while the device is unlocked. Open files are accessible even when the device is locked. New files can be created and accessed whether the device is locked or unlocked.
  • Complete. The file will remain encrypted until the user unlocks the device and becomes encrypted when the user locks it.

You've got two options to set the encryption level for a file:

Option 1: Using the

write(to:options:)]
method. With this, you can create a file and assign it a protection level all at once:

do {
  try data.write(to: fileURL, options: .completeFileProtection)
} catch {
  // Handle errors.
}

You can check out the encryption options here.

Option 2: Setting the data protection level for an existing file using the

setResourceValue(_:forKey:)
method:

do {
  try (fileURL as NSURL)
    .setResourceValue(
      URLFileProtection.complete,
      forKey: .fileProtectionKey
    )
  } catch {
    // Handle errors.
}

UserDefaults

Sometimes you need to store some user preferences. For example, the user might want the sign-in screen to remember them. You’ll want to persist this kind of information between application launches.

Through the use of the

UserDefaults
class, Apple gives us an easy way to save and retrieve information as key-value pairs.

// Save the boolean value `true` under the key `rememberUser`
UserDefaults.standard.set(true, forKey: "rememberUser")

// Retrieve the boolean value stored under the key `rememberUser`
let rememberUser = UserDefaults.standard.bool(forKey: "rememberUser")

The problem is that it’s too easy to use

UserDefaults
. This ease of use can mean that you might end up using it to store sensitive information — such as API tokens, for instance.

You should never use

UserDefaults
to store data that could compromise your application. When using
UserDefaults
to store some piece of data, ask yourself: "What could happen if someone accessed the information in UserDefaults?" If any of the answers mean that your application could be breached, you shouldn't store that data in
UserDefaults
.

Keychain

If you need to store sensitive data, use Keychain Services. Unlike UserDefaults, the data stored in the keychain is automatically encrypted.

With the keychain, you don't need to save encryption keys. Every application has its own isolated keychain section that other applications can’t access.

In addition, you can choose from these keychains:

The local keychain, which is available only on the device. The user’s iCloud keychain, which synchronizes with all the devices registered with the same Apple ID.

You can also specify security policies for the data stored in the keychain. For example, you could require FaceID to access the data.

The bad news is that the keychain API is not as simple as the

UserDefaults
API. This is why many developers end up relying on
UserDefaults
, even when storing sensitive data.

You'll need to use the

SecItemAdd(::)
method to add a new item into the keychain, and
SecItemCopyMatching(::)
to retrieve items.

Data communications channels

When developing an application, the next subject you must look at is how you communicate with the services outside your application. There's no point in securing all the data storage-related stuff if it’s easy to capture the information you send to your back end.

App transport security

This feature improves the network security between your application and the outside world and has been available since iOS 9. App Transport Security (ATS) blocks insecure connections by default, requiring you to use HTTPS rather than HTTP.

Although it’s highly recommended to leave ATS enabled, you can disable it for specific domains if you want, which allows you to access HTTP domains. You can specify these unsecured domains in the

info.plist
file.

SSL certificate pinning

When making connections to a server over HTTPS, iOS checks the validity of the server’s certificate. For a certificate to be valid, it has to be issued by a valid Certificate Authority (CA).

Certificates do a decent job preventing monkey-in-the-middle attacks, but they’re not a perfect solution. Hackers can still bypass them with self-signed certificates or hacking a root CA certificate.

To prevent this problem, you have a couple of options:

  • Download your server's certificate and store it in your application. When the app connects to the server and presents its certificate, compare it with the one stored in your application.
  • Store the public key for the server's certificate in your application's code. When the app connects to the server and presents its certificate, compare its public key with the one stored in your application.

Jailbroken Devices

For iOS devices, jailbreaking gives the OS unauthorized privileges and root user access. It allows you to perform actions that wouldn’t otherwise be possible.

Every iOS application that we want to distribute needs to go through the App Store. This allows Apple to inspect and approve apps and reduces the odds that users will install malware.

The App Store is supposed to be the only source for apps. However, jailbreaking allows users to download non-approved apps from alternatives to the App Store, such as Cydia.

How does this impact you as a developer? A jailbroken device can run unauthorized apps to hack into your applications.

You should consider having your app check the device for jailbreaking. You can do so with this code:

import Foundation
import UIKit

extension UIDevice {
  var isSimulator: Bool {
    return TARGET_OS_SIMULATOR != 0
  }


  var isJailBroken: Bool {
    if UIDevice.current.isSimulator { return false }
    if JailBrokenHelper.hasCydiaInstalled() { return true }
    if JailBrokenHelper.containsSuspiciousApps() { return true }
    if JailBrokenHelper.hasSuspiciousSystemPaths() { return true }
    return JailBrokenHelper.canEditSystemFiles()
  }
}

private struct JailBrokenHelper {
  static func hasCydiaInstalled() -> Bool {
    return UIApplication.shared.canOpenURL(URL(string: "cydia://package/com.example.package")!)
  }


  static func containsSuspiciousApps() -> Bool {
    for path in suspiciousAppsPathToCheck {
      if FileManager.default.fileExists(atPath: path) {
        return true
      }
    }
    return false
  }


  static func hasSuspiciousSystemPaths() -> Bool {
    for path in suspiciousSystemPathsToCheck {
      if FileManager.default.fileExists(atPath: path) {
        return true
      }
    }
    return false
  }


  static func canEditSystemFiles() -> Bool {
    let jailBreakText = "Developer Insider"
      do {
        try jailBreakText.write(toFile: "/private/jailbreak.txt", atomically: true, encoding: .utf8)
        return true
      } catch {
        return false
      }
    }


  /**
   Add more paths here to check for jail break
  */
  static var suspiciousAppsPathToCheck: [String] {
    return [
      "/Applications/Cydia.app",
      "/Applications/blackra1n.app",
      "/Applications/FakeCarrier.app",
      "/Applications/Icy.app",
      "/Applications/IntelliScreen.app",
      "/Applications/MxTube.app",
      "/Applications/RockApp.app",
      "/Applications/SBSettings.app",
      "/Applications/WinterBoard.app",
      "/Applications/LibertyLite.app"
    ]
  }


  static var suspiciousSystemPathsToCheck: [String] {
    return [
      "/Library/MobileSubstrate/DynamicLibraries/LiveClock.plist",
      "/Library/MobileSubstrate/DynamicLibraries/Veency.plist",
      "/Library/MobileSubstrate/MobileSubstrate.dylib",
      "/System/Library/LaunchDaemons/com.ikey.bbot.plist",
      "/System/Library/LaunchDaemons/com.saurik.Cydia.Startup.plist",
      "/bin/bash",
      "/bin/sh",
      "/etc/apt",
      "/etc/ssh/sshd_config",
      "/private/var/tmp/cydia.log",
      "/var/tmp/cydia.log",
      "/usr/bin/sshd",
      "/usr/libexec/sftp-server",
      "/usr/libexec/ssh-keysign",
      "/usr/sbin/sshd",
      "/var/cache/apt",
      "/var/lib/apt",
      "/var/lib/cydia",
      "/usr/sbin/frida-server",
      "/usr/bin/cycript",
      "/usr/local/bin/cycript",
      "/usr/lib/libcycript.dylib",
      "/var/log/syslog",
      "/private/var/lib/apt",
      "/private/var/lib/cydia",
      "/private/var/mobile/Library/SBSettings/Themes",
      "/private/var/stash"
    ]
  }
}

Please be aware that this is not a foolproof way to detect jailbreaking on a device. Security is an active field, and new malware is released every day. You need to stay up to date and add to the code above as new vulnerabilities appear.

Development techniques

You can also incorporate techniques to prevent your code from giving away information to malicious parties.

Printing logs

Developers often call the

print()
function for debugging. The problem is that they can forget to remove these calls, which might contain some information that an attacker could use to breach your security.

A good practice is to use compiler flags so that

print()
works only in debug mode:

#if DEBUG
print("Log")
#endif

Environment files

Another part of your application that you should review is the environment information. Many developers overlook this, even though it could contain sensitive information such as your API URL, API keys, and so on.

By default, Apple does a decent job obfuscating your code when compiling to upload it to the App Store. In spite of this, it’s still possible for a hacker to reverse-engineer the environment information.

You should never leave this type of information hardcoded. Instead, you should use configuration files (such as

.plist
files, for example), and you should never commit them to your repository.

Apple CryptoKit

CryptoKit was presented at WWDC2019 as the new framework to perform cryptographic operations in iOS. It includes the most popular encryption and hashing algorithms. Let's take a look at some of CryptoKit’s features.

Hashing

A hash function takes an input of any size and returns an output of a fixed size called a digest or hash. Keep these in mind when thinking about hash functions:

  • They’re not reversible. You can’t compute a hash function’s original input from the resulting digest.
  • If you feed two different inputs X and Y into the same hash function and both produce the same digest Z, X and Y are the same (except in incredibly rare cases).

Here’s how you can use hash functions to send a message to a friend and confirm it hasn’t been altered by a third party:

  1. You enter the message into a hash function to produce a digest.
  2. You send the message along with the digest.
  3. Your friend receives the message with the digest.
  4. Your friend applies the same hash function to the message.
  5. If resulting digest matches the one you sent with the message, your friend can be assured that the message wasn't compromised.

CryptoKit features these three hash functions: SHA256, SHA384, and SHA512. Here’s how you use these functions:

import CryptoKit

let dataToHash = "Hash test".data(using: .utf8)!
let sha256Hash = SHA256.hash(data: dataToHash)
let sha384Hash = SHA284.hash(data: dataToHash)
let sha512Hash = SHA512.hash(data: dataToHash)

Authentication Data

You may need to make sure that the person communicating with you is who they claim to be. You can use Message Authentication Code (MAC), which is additional information attached to the message that authenticates the sender and confirms the message's integrity as well.

CryptoKit implements MAC as Hash Message Authentication Code (HMAC), a type of MAC that uses a symmetric key to generate the code.

Here’s an example of CryptoKit’s HMAC in use:

import CryptoKit

let key = SymmetricKey(size: .bits256) // 1

// Sender side
let message = "Hi John, how are you?".data(using: .utf8)! // 2
let hmacCode = HMAC<SHA256>.authenticationCode(for: message, using: key) // 3

// Receiver side
let hmacCodeData = Data(hmacCode) // 4
if HMAC<SHA256>.isValidAuthenticationCode( // 5
  hmacCodeData,
  authenticating: message,
  using: key) {
  print("MAC code is valid!")
}

Here’s what’s happening at the numbered comments in the code above:

  1. Generate a new random key. This key must be shared between the sender and receiver.
  2. Create the message.
  3. Generate a new HMAC using the SHA256 hash function.
  4. Generate a
    Data
    instance from the HMAC received.
  5. Check if the HMAC received is valid.

Encryption

If you need to send sensitive data to the server, you should encrypt it first. CryptoKit provides two encryption options:

Here’s an example of ChaChaPoly in use:

import CryptoKit

let key = SymmetricKey(size: .bits256) // 1

let message = "Hi John, how are you?".data(using: .utf8)! // 2

let encryptedMessage = try! ChaChaPoly.seal(message, using: key) // 3
let sealedBox = try! ChaChaPoly.SealedBox(combined: encryptedMessage.combined) // 4

let decryptedMessage = try! ChaChaPoly.open(sealedBox, using: key) // 5

assert(message == decryptedMessage)

Here’s what’s happening at the numbered comments in the code above:

  1. Generate a new encryption/decryption key. The application and server share this key.
  2. Create the message.
  3. Encrypt the message using ChaChaPoly and the shared key.
  4. Seal the encrypted message. This function will return a
    SealedBox
    struct that contains:
    • A nonce: A random value used only once during a cryptographic operation in order to prevent replay attacks. “Nonce” is derived from the phrase “number once”. If you don’t provide a nonce value to the
      seal()
      function, CryptoKit will automatically generate a value for you.
    • ciphertext: The encrypted message.
    • tag: An authentication tag to ensure that the message wasn't modified in transit.
    • combine: A combination of all three previous values — nonce, ciphertext, and tag.
  5. Open the sealed box containing the encrypted message using the shared key.

Key Agreement

If you have an architecture where you communicate with multiple peers, sharing a private secret might not be a good solution. You will need to generate a new key for each one, and every time you add a peer to the system, you must generate a new key and deploy a new version of your application.

A key agreement using public keys is the best solution for these cases. Each node of the network will have a public key that everyone else will know. And, from that public key, you will generate a symmetric key to cipher your data. Then, the other part will do the same with your public key.

Here’s an example implementation of key agreement featuring two peers, “Peter” and “Jane”:

import CryptoKit

// 1 >>>
let peterPrivateKey = P256.KeyAgreement.PrivateKey()
let peterPublicKey = peterPrivateKey.publicKey

let janePrivateKey = P256.KeyAgreement.PrivateKey()
let janePublicKey = janePrivateKey.publicKey
// <<<

// 2 >>>
let peterSharedSecret = try! peterPrivateKey.sharedSecretFromKeyAgreement(with: janePublicKey)
let peterSymmetricKey = peterSharedSecret.hkdfDerivedSymmetricKey(
  using: SHA256.self,
  salt: "Some random salt".data(using: .utf8)!,
  sharedInfo: Data(),
  outputByteCount: 32
)

let janeSharedSecret = try! janePrivateKey.sharedSecretFromKeyAgreement(
  with: peterPublicLey)
let janeSymmetricKey = janeSharedSecret.hkdfDerivedSymmetricKey(
  using: SHA256.self,
  salt: "Some random salt".data(using: .utf8)!,
  sharedInfo: Data(),
  outputByteCount: 32)
// <<<

assert(peterSymmetricKey == janeSymmetricKey)

Here’s what the code does (see the comments that divide it into numbered sections):

  1. Each peer creates a new public/private key pair and makes its public key available to the others. In this example, Peter will know Jane's public key, and Jane will know Peter's public key.
  2. Each peer will generate a symmetric key to use with each other:
    1. First, each peer generates a shared secret from their counterpart’s public key (which is available and everybody in the network knows) and their own private key.
    2. Each peer generates the symmetric key based on that shared secret using the
      hkdfDerivedSymmetricKey()
      method, which has the following parameters:
      • using
        : The hash algorithm to use.
      • salt
        : A salt to use in the key generation
      • sharedInfo
        : The shared information to use for the key generation.
        • outputByteCount
          : The length of the symmetric key, in bytes.

Now you can use the generated symmetric key to encrypt and decrypt information passed between Peter and Jane.

Signature Verification

There may be cases where you'll need to sign the information before you send it so the recipient of the message can be sure that you — and not someone pretending to be you — sent the message.

import CryptoKit

let signingKey = Curve25519.Signing.PrivateKey() // 1
let signingPublicKey = signingKey.publicKey // 2

// 3
let signature = try! signingKey.signature(
  for: "Some sample Data to sign.".data(using: .utf8)!
)

// 4
if signingPublicKey.isValidSignature(signature, for: dataToSign) {
  print("The signature is valid.")
}

Here’s what’s happening at the numbered comments in the code above:

  1. Generate a new private key for signing.
  2. Make the public key available.
  3. Sign the data with your signing key.
  4. The receiver will check the signature using your public key.

Conclusion

Mobile security cannot be an afterthought. It’s something that you need to consider throughout the development process, from design to development to QA. Keep in mind that security often involves trade-offs and costs time and resources. The level of work in securing a “to-do list” app will differ from what you’d need to do for a banking application.

It’s important to know the security risks involved in application development, and it’s even more important to know how to mitigate them. iOS provides developers with a rich set of libraries, and the introduction of CryptoKit gives us a solid security foundation with easy-to-use high-level APIs.

There is no such thing as a perfect defense. Given enough time and resources, a determined hacker will be able to penetrate any system. Instead, we employ security techniques to make the hacker’s job as difficult as possible. Your goal is to harden your application so that hacking it isn’t worth their time.