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 askSecClassGenericPassword
indicating that the data we are saving is a generic password item.kSecAttrService
andkSecAttrAccount
: These 2 keys are mandatory whenkSecClass
is set tokSecClassGenericPassword
. 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.