VNX-SWIFT-003 – Swift Insecure Data Storage via UserDefaults for Sensitive Values
Overview
This rule reports a Swift write to UserDefaults (set, setValue or setObject on UserDefaults.standard, UserDefaults(suiteName:), NSUserDefaults.standardUserDefaults(), or a variable the file declares as UserDefaults) whose value or key names a credential, and a SwiftUI @AppStorage property, which is backed by UserDefaults, whose key or property name does. A credential is a name ending with password, passwd, passcode, secret, token, apiKey, privateKey, credential(s), pinCode/PIN, sessionId, cardNumber, cvv or ssn, or a password text field’s .text.
It does not report booleans, numbers, dates and nil under any key, keys that describe rather than hold a credential (hasSeenTokenPrompt, tokenExpiry), reads and removeObject, set(_:forKey:) on receivers that are not UserDefaults, Keychain code, string literals, comments and test files.
UserDefaults (formerly NSUserDefaults) serialises data to a plaintext property list file stored at Library/Preferences/<bundle-id>.plist in the application sandbox. This file is not encrypted at the application layer. While iOS encrypts the file system at rest using hardware-backed data protection, the plist is stored under the default protection class NSFileProtectionCompleteUntilFirstUserAuthentication, which means it is accessible whenever the device has been unlocked at least once after boot — including while the screen is locked.
This maps to CWE-311: Missing Encryption of Sensitive Data.
Severity: High | CWE: CWE-311 – Missing Encryption of Sensitive Data
Why This Matters
The UserDefaults plist is included in iTunes and iCloud device backups by default. An attacker with access to a backup — obtained from an insecure backup destination, a compromised macOS machine, or a social engineering attack against Apple support — can extract the plist and read all stored values in plain text using any standard property list editor.
On jailbroken devices, any process running as mobile can read another application’s preferences plist without special permissions. Malware on a jailbroken device routinely harvests UserDefaults from banking, email, and social applications to collect session tokens. Additionally, MDM profiles can read application preferences on enrolled devices, creating a risk of credential exposure in enterprise deployments.
iOS provides the Keychain specifically to address these threats. The Keychain encrypts each item with a key derived from the device passcode and the item’s accessibility class. Items marked kSecAttrAccessibleWhenUnlockedThisDeviceOnly can only be read when the device is unlocked and cannot be restored to a different device, providing strong protection against backup extraction and device transfer attacks.
What Gets Flagged
// FLAGGED: authentication token written to UserDefaults
UserDefaults.standard.set(authToken, forKey: "auth_token")
// FLAGGED: password persisted in preferences
UserDefaults.standard.set(password, forKey: "userPassword")
// FLAGGED: API key stored unencrypted
defaults.set(apiKey, forKey: "api_key")
// FLAGGED: private key material in UserDefaults
UserDefaults.standard.set(privateKey, forKey: "privateKey")
// FLAGGED: SwiftyUserDefaults subscript and a credential dictionary
Defaults[\.authToken] = response.token
UserDefaults.standard.set(["username": user, "password": pass], forKey: "lastLogin")
// NOT FLAGGED: flags, dates and encrypted values
UserDefaults.standard.set(true, forKey: "hasStoredToken")
UserDefaults.standard.set(try encrypt(token), forKey: "blob")
Remediation
Replace all
UserDefaultswrites of sensitive data with Keychain writes. The iOS Security framework’s Keychain services API provides hardware-backed encryption at no additional complexity cost.// SAFE: store a credential in the Keychain import Security enum KeychainError: Error { case saveFailed(OSStatus) case loadFailed(OSStatus) case itemNotFound } func saveToKeychain(value: String, key: String) throws { guard let data = value.data(using: .utf8) else { return } let query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrService: Bundle.main.bundleIdentifier ?? "app", kSecAttrAccount: key, kSecValueData: data, kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, ] // Delete any existing item before adding SecItemDelete(query as CFDictionary) let status = SecItemAdd(query as CFDictionary, nil) guard status == errSecSuccess else { throw KeychainError.saveFailed(status) } }Use a Keychain wrapper library to reduce boilerplate. Libraries such as KeychainAccess or SwiftKeychainWrapper provide a
UserDefaults-like interface backed by the Keychain, making migration straightforward.// SAFE: Keychain wrapper with familiar interface import KeychainAccess let keychain = Keychain(service: "com.example.MyApp") try keychain.set(authToken, key: "auth_token") let token = try keychain.getString("auth_token")Audit all
UserDefaultsusage. Search for.set(calls onUserDefaultsinstances and review every key being written. Non-sensitive preferences (theme, locale, feature flags) can remain inUserDefaults; credentials, tokens, and keys must move to the Keychain.Apply the correct accessibility class. Use
kSecAttrAccessibleWhenUnlockedThisDeviceOnlyfor most credentials. AvoidkSecAttrAccessibleAlwaysor any class withoutThisDeviceOnlyunless you explicitly need iCloud Keychain synchronisation and have assessed the implications.Migrate existing users’ data. On first launch after the update, read any credentials previously stored in
UserDefaults, move them to the Keychain, and then delete theUserDefaultsentries.
References
- CWE-311: Missing Encryption of Sensitive Data
- OWASP Mobile Security Testing Guide – MSTG-STORAGE-1 (Local Storage)
- Apple – Keychain Services
- Apple – Protecting user data with improved data protections
- Apple – SecItemAdd
- CAPEC-37: Retrieve Embedded Sensitive Data
- MITRE ATT&CK T1409 – Stored Application Data