You are currently viewing Persisting Sensitive Data Using Keychain in Swift

Persisting Sensitive Data Using Keychain in Swift

When developing an iOS app, oftentimes we need to store sensitive data (password, access token, secret key, etc) locally. For junior developers, the first thing that comes to mind will be storing it using UserDefaults. However, as we all know, storing sensitive data using UserDefaults is a very bad idea, it is because data stored using UserDefaults is not encrypted and extremely insecure.

In order to securely store sensitive data locally, we should use the keychain service provided by Apple. It is a fairly old framework, therefore as you will see later, its APIs are not as Swifty as other modern frameworks by Apple.

In this article, I will show you how to create a generic helper class that works in both iOS and macOS, that saves, updates, reads and deletes data using the keychain service. That’s a lot of topics to cover, so let’s get right into it!


Saving Data to Keychain

As mentioned earlier, we will be creating a helper class in the article. For the sake of simplicity, let’s make the helper class a singleton class:

final class KeychainHelper {
    
    static let standard = KeychainHelper()
    private init() {}
    
    // Class implementation here...
}

In order to save data to the keychain, we must leverage the SecItemAdd(_:_:) method that accepts a query object of type CFDictionary.

The idea is to create a query object that contains the data we want to save as well as the primary key associated with the data. After that, we will perform the save operation by feeding the query object to the SecItemAdd(_:_:) method.

func save(_ data: Data, service: String, account: String) {
    
    // Create query
    let query = [
        kSecValueData: data,
        kSecClass: kSecClassGenericPassword,
        kSecAttrService: service,
        kSecAttrAccount: account,
    ] as CFDictionary
    
    // Add data in query to keychain
    let status = SecItemAdd(query, nil)
    
    if status != errSecSuccess {
        // Print out the error
        print("Error: \(status)")
    }
}

As can be seen from the above code snippet, the query object consist of 4 dictionary keys, let’s go through them in details:

  • kSecValueData: A key that represents the data being saved to the keychain.
  • kSecClass: A key that represents the type of data being saved. Here we set its value as kSecClassGenericPassword indicating that the data we are saving is a generic password item.
  • kSecAttrService and kSecAttrAccount: These 2 keys are mandatory when kSecClass is set to kSecClassGenericPassword. The values for both of these keys will act as the primary key for the data being saved. In other words, we will use them to retrieve the saved data from the keychain later on.

Pro Tip:

Check out this and this documentation to learn about other possible values for kSecClass and their respective primary keys.

There is no hard defined rule what value to use for both kSecAttrService and kSecAttrAccount. However, it is recommended to use strings that are meaningful. For example, if we are saving the Facebook access token, we can set kSecAttrService as “access-token” and kSecAttrAccount as “facebook“.

After creating the query object, we can then call the SecItemAdd(_:_:) method to save the data to the keychain. The SecItemAdd(_:_:) method will then return an OSStatus that indicates the status of the save operation. If we get the errSecSuccess status, it means that the data has been successfully saved to the keychain.

Here’s how to use the save(_:service:account:) method we just created:

let accessToken = "dummy-access-token"
let data = Data(accessToken.utf8)
KeychainHelper.standard.save(data, service: "access-token", account: "facebook")

Keychain doesn’t work in Xcode playground, therefore you can run the above code either in a view controller, a SwiftUI view or you can run it as a unit test case.

Note:

If you would like to learn more about unit testing, check out my previous articles related to unit tests.


Updating Existing Data in Keychain

Now that we have the save(_:service:account:) method in place, let’s try to save another token using the same kSecAttrService and kSecAttrAccount value:

let accessToken = "another-dummy-access-token"
let data = Data(accessToken.utf8)
KeychainHelper.standard.save(data, service: "access-token", account: "facebook")

This time, we won’t be able to save the access token to the keychain. Instead, we will get a message in the Xcode console saying “Error: -25299“. The error code -25299 indicates that the save operation failed because the keys that we use already exist in the keychain.

To go about this problem, we need to check for the -25299 error code (equivalent to errSecDuplicateItem) and then update the keychain using the SecItemUpdate(_:_:) method. Let’s go ahead and update our previous save(_:service:account:) method:

func save(_ data: Data, service: String, account: String) {

    // ... ...
    // ... ...

    if status == errSecDuplicateItem {
        // Item already exist, thus update it.
        let query = [
            kSecAttrService: service,
            kSecAttrAccount: account,
            kSecClass: kSecClassGenericPassword,
        ] as CFDictionary

        let attributesToUpdate = [kSecValueData: data] as CFDictionary

        // Update existing item
        SecItemUpdate(query, attributesToUpdate)
    }
}

Similar to the save operation, we must first create a query object that consists of kSecAttrService and kSecAttrAccount. But this time, we will have to create another dictionary that consists of kSecValueData and feed it to the SecItemUpdate(_:_:) method.

With that, we have made our save(_:service:account:) method able to update any existing items in the keychain.


Reading Data from Keychain

The way to read data from the keychain is very similar to how we save data to it. We first create a query object and then we call a method to get the data from keychain:

func read(service: String, account: String) -> Data? {
    
    let query = [
        kSecAttrService: service,
        kSecAttrAccount: account,
        kSecClass: kSecClassGenericPassword,
        kSecReturnData: true
    ] as CFDictionary
    
    var result: AnyObject?
    SecItemCopyMatching(query, &result)
    
    return (result as? Data)
}

As usual, we need to set the value for kSecAttrService and kSecAttrAccount to the query object. On top of that, we also need to include a new key, kSecReturnData, in the query object and set its value to true. This indicates that we want the query to return the item data.

After that, we will leverage the SecItemCopyMatching(_:_:) method and pass in a result object of type AnyObject by reference. This result object will hold the item data requested by the query object.

Lastly, we will convert the result object to Data and return it. One thing worth noting is that, just like the SecItemAdd(_:_:) method, the SecItemCopyMatching(_:_:) method will also return an OSStatus that indicates the read operation status, but we won’t do any checking here as we will just return nil if the read operation failed.

That’s all it takes to make our keychain helper class support read operation. Let’s see it in action:

let data = KeychainHelper.standard.read(service: "access-token", account: "facebook")!
let accessToken = String(data: data, encoding: .utf8)!
print(accessToken)

Deleting Data from Keychain

Our keychain helper class won’t be complete if it does not support the delete operation. Go ahead and add in the following code snippet:

func delete(service: String, account: String) {
    
    let query = [
        kSecAttrService: service,
        kSecAttrAccount: account,
        kSecClass: kSecClassGenericPassword,
        ] as CFDictionary
    
    // Delete item from keychain
    SecItemDelete(query)
}

If you have been following along, the code above should look familiar to you. It is pretty much self-explanatory, just note that here we are using the SecItemDelete(_:) method to delete items from the keychain.


Creating a Generic Keychain Helper Class

At this stage, we have implemented all the necessary functionalities of the keychain helper class. However, there is 1 limitation — it only supports reading and writing items of type Data. Wouldn’t it be great if we could store objects of any data type to the keychain?

The idea here is to create a generic save method that accepts any object with data type that conforms to the Codable protocol. With that, we will be able to encode the given object using JSONEncoder and store it using the save(_:service:account:) method we created earlier.

func save<T>(_ item: T, service: String, account: String) where T : Codable {
    
    do {
        // Encode as JSON data and save in keychain
        let data = try JSONEncoder().encode(item)
        save(data, service: service, account: account)
        
    } catch {
        assertionFailure("Fail to encode item for keychain: \(error)")
    }
}

By using the same idea, creating a generic read method is pretty straightforward. We will use the JSONDecoder to decode the data obtained from the keychain and return it.

func read<T>(service: String, account: String, type: T.Type) -> T? where T : Codable {
    
    // Read item data from keychain
    guard let data = read(service: service, account: account) else {
        return nil
    }
    
    // Decode JSON data to object
    do {
        let item = try JSONDecoder().decode(type, from: data)
        return item
    } catch {
        assertionFailure("Fail to decode item for keychain: \(error)")
        return nil
    }
}

With that we have successfully made our keychain helper class generic. Let’s see it in action:

struct Auth: Codable {
    let accessToken: String
    let refreshToken: String
}

// Create an object to save
let auth = Auth(accessToken: "dummy-access-token",
                 refreshToken: "dummy-refresh-token")

let account = "domain.com"
let service = "token"

// Save `auth` to keychain
KeychainHelper.standard.save(auth, service: service, account: account)

// Read `auth` from keychain
let result = KeychainHelper.standard.read(service: service,
                                          account: account,
                                          type: Auth.self)!

print(result.accessToken)   // Output: "dummy-access-token"
print(result.refreshToken)  // Output: "dummy-refresh-token"

If you would like to try out the keychain helper class in your own project, you can get the full sample code here.


Wrapping Up

When creating an app, it is our responsibility to protect our users’ privacy and make sure that their sensitive data are securely stored within the app. Therefore, if you are still saving your users’ sensitive data using UserDefaults, you should definitely stop doing that, and start using keychains instead!

If you enjoy reading this article and would like to get notified when new articles come out, feel free to follow me on Twitter and subscribe to my newsletter.

Thanks for reading. 👨🏻‍💻


👋🏻 Hey!

While you’re still here, why not check out some of my favorite Mac tools on Setapp? They will definitely help improve your day-to-day productivity. Additionally, doing so will also help support my work.

  • Bartender: Superpower your menu bar and take full control over your menu bar items.
  • CleanShot X: The best screen capture app I’ve ever used.
  • PixelSnap: Measure on-screen elements with ease and precision.
  • iStat Menus: Track CPU, GPU, sensors, and more, all in one convenient tool.