# Insecure Patterns in Generated iOS Code

This is the review backbone of the skill. It lists the ten security mistakes that code generators produce most often on Apple platforms. Every entry gives the reason it is dangerous, an insecure sample, a corrected sample, a way to detect it, and its OWASP mapping.

Deeper material per entry: [credential-storage-patterns.md](credential-storage-patterns.md) (1 and 2), [biometric-authentication.md](biometric-authentication.md) (3), [keychain-fundamentals.md](keychain-fundamentals.md) (4), [keychain-access-control.md](keychain-access-control.md) (5), [cryptokit-symmetric.md](cryptokit-symmetric.md) (6 and 7), [migration-legacy-stores.md](migration-legacy-stores.md) (9), and [compliance-owasp-mapping.md](compliance-owasp-mapping.md) for the complete standards mapping.

## Contents

- [Why generated code drifts toward insecure defaults](#why-generated-code-drifts-toward-insecure-defaults)
- [1. Secrets in UserDefaults](#1-secrets-in-userdefaults)
- [2. Hardcoded API keys](#2-hardcoded-api-keys)
- [3. Biometric check that only returns a Boolean](#3-biometric-check-that-only-returns-a-boolean)
- [4. Ignoring SecItem status codes](#4-ignoring-secitem-status-codes)
- [5. Missing or weak data protection class](#5-missing-or-weak-data-protection-class)
- [6. Reusing an AES-GCM nonce](#6-reusing-an-aes-gcm-nonce)
- [7. MD5 or SHA-1 where security matters](#7-md5-or-sha-1-where-security-matters)
- [8. Secrets in logs](#8-secrets-in-logs)
- [9. No keychain cleanup after reinstall](#9-no-keychain-cleanup-after-reinstall)
- [10. Weak randomness for security values](#10-weak-randomness-for-security-values)
- [Quick reference](#quick-reference)
- [Automating detection in CI](#automating-detection-in-ci)
- [What iOS 26 changes](#what-ios-26-changes)
- [Checklist](#checklist)

## Why generated code drifts toward insecure defaults

Generators are rewarded for code that compiles and works, and their training data is full of tutorials that store tokens in `UserDefaults` or gate screens on a biometric Boolean. The insecure version is usually the shortest one, so it wins.

Published numbers point the same way. A 2025 industry study found that 45% of AI-produced code failed security tests. A 2025 scan of about 156,000 iOS apps found more than 815,000 hardcoded secrets, with 71% of apps leaking at least one. An academic user study found that developers with an AI assistant wrote less secure code while feeling more confident in it. The CISA and FBI "Product Security Bad Practices" guidance (version 2.0, January 2025) names hardcoded credentials (CWE-798) as a practice that raises risk to national security.

Mappings below use the OWASP Mobile Top 10 (2024) and MASTG v2 test identifiers, with legacy MSTG identifiers in parentheses where auditors still quote them.

The samples share two small error types: `KeychainFailure(status)` wraps an `OSStatus`, and `VaultError` carries app-level cases such as `deviceLocked` and `authenticationFailed`.

## 1. Secrets in UserDefaults

Severity: Critical. OWASP: M9. Fix effort: Medium.

`UserDefaults` writes an unencrypted property list to `Library/Preferences/<bundle id>.plist` inside the container, and Apple's documentation says not to keep sensitive data there. The file shows up in unencrypted backups, on jailbroken devices (`objection` reads it with `ios nsuserdefaults get`), and to any third-party SDK in the process. SwiftUI's `@AppStorage` is the same store with a property wrapper around it, so it is equally unsuitable for tokens, keys or credentials.

Insecure:

```swift
let prefs = UserDefaults.standard
prefs.set(session.accessToken, forKey: "access_token")
prefs.set(session.refreshToken, forKey: "refresh_token")
prefs.synchronize()
```

Secure:

```swift
func storeAccessToken(_ token: String) throws {
    let identity: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
                                     kSecAttrService: "com.example.courier",
                                     kSecAttrAccount: "access_token"]
    var insert = identity
    insert[kSecValueData] = Data(token.utf8)
    insert[kSecAttrAccessible] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
    var status = SecItemAdd(insert as CFDictionary, nil)
    if status == errSecDuplicateItem {
        let changes: [CFString: Any] = [kSecValueData: Data(token.utf8)]
        status = SecItemUpdate(identity as CFDictionary, changes as CFDictionary)
    }
    guard status == errSecSuccess else { throw KeychainFailure(status) }
}
```

Standards: MASTG-TEST-0300, MASTG-TEST-0302, MASWE-0006 (legacy MSTG-STORAGE-1).

Detection: case-insensitive search of `.swift` files for lines containing `UserDefaults` together with token, password, secret, credential, auth, session, api key, jwt or bearer.

```sh
grep -rniE 'UserDefaults.*(token|password|secret|credential|auth|session|api_?key|jwt|bearer)' --include='*.swift' .
```

## 2. Hardcoded API keys

Severity: Critical. OWASP: M1. Fix effort: High.

A Swift string literal ends up in the binary's `__TEXT.__cstring` section, where `strings` prints it in seconds. Values in `.xcconfig` files or `Info.plist` ship inside the IPA just the same. The 2025 scan mentioned above found about 78,800 keys for a single cloud provider's APIs alone.

Insecure:

```swift
enum Secrets {
    static let paymentsKey = "sk_live_EXAMPLEONLY000000000000"
    static let backendKey = "AIzaEXAMPLEONLY00000000000000000"
}
request.setValue("Bearer \(Secrets.paymentsKey)", forHTTPHeaderField: "Authorization")
let plistKey = Bundle.main.infoDictionary?["API_KEY"] as? String
```

Secure, in order of preference:

1. A server-side proxy holds the key and the app calls the proxy, so the key never reaches the device.
2. If the device genuinely needs a key, fetch it at runtime from your server after the user or device is authenticated and cache it in the keychain.

```swift
func quoteShipment(_ order: ShipmentRequest) async throws -> Quote {
    var request = URLRequest(url: quotesEndpoint)
    request.httpMethod = "POST"
    request.httpBody = try JSONEncoder().encode(order)
    request.setValue("Bearer \(try await session.userAccessToken())", forHTTPHeaderField: "Authorization")
    let (body, _) = try await URLSession.shared.data(for: request)
    return try JSONDecoder().decode(Quote.self, from: body)
}
```

DeviceCheck and App Attest let the server verify a genuine app instance without shipping any secret (details in the `device-integrity` skill). WWDC 2019 session 709 states the rule plainly: credentials belong in the keychain.

Standards: MASTG-TEST-0213, MASTG-TEST-0214, MASWE-0005 (legacy MSTG-STORAGE-12); CWE-798 per the CISA/FBI guidance.

Detection: in `.swift`, `.plist` and `.xcconfig` files, look for `let` constants whose name contains "key" assigned a literal of 20 or more alphanumeric characters, and for the prefixes `"sk_live_`, `"pk_live_`, `"AIza` and `"AKIA`.

```sh
grep -rnE 'let [A-Za-z]*[Kk]ey[A-Za-z]* *= *"[A-Za-z0-9_-]{20,}"' --include='*.swift' .
grep -rnE '"(sk_live_|pk_live_|AIza|AKIA)' --include='*.swift' --include='*.plist' --include='*.xcconfig' .
```

## 3. Biometric check that only returns a Boolean

Severity: Critical. OWASP: M3. Fix effort: Medium.

This is the insecure pattern generators reproduce most. `evaluatePolicy` hands back a `Bool` in the app's own process, and one Frida command or `objection`'s `ios ui biometrics_bypass` flips it. MASTG's position is that biometric authentication must unlock something in the keychain, not just a screen.

Insecure:

```swift
LAContext().evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
                           localizedReason: "Show your balance") { granted, _ in
    if granted { DispatchQueue.main.async { self.revealBalance() } }
}
```

Secure: bind the secret to a keychain item whose access control demands biometrics, and read it with an `LAContext` attached.

```swift
func sealBalanceKey(_ secret: Data) throws {
    guard let gate = SecAccessControlCreateWithFlags(nil,
            kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .biometryCurrentSet, nil) else {
        throw KeychainFailure(errSecParam)
    }
    let identity: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
                                     kSecAttrService: "com.example.courier",
                                     kSecAttrAccount: "balance-key"]
    var insert = identity
    insert[kSecValueData] = secret
    insert[kSecAttrAccessControl] = gate
    var status = SecItemAdd(insert as CFDictionary, nil)
    if status == errSecDuplicateItem {
        status = SecItemUpdate(identity as CFDictionary, [kSecValueData: secret] as CFDictionary)
    }
    guard status == errSecSuccess else { throw KeychainFailure(status) }
}

func openBalanceKey() throws -> Data {
    let context = LAContext()
    context.localizedReason = "Show your balance"
    let lookup: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
                                   kSecAttrService: "com.example.courier",
                                   kSecAttrAccount: "balance-key",
                                   kSecReturnData: true,
                                   kSecUseAuthenticationContext: context]
    var out: CFTypeRef?
    let status = SecItemCopyMatching(lookup as CFDictionary, &out)
    guard status == errSecSuccess, let secret = out as? Data else { throw KeychainFailure(status) }
    return secret
}
```

A duplicate is updated, never silently accepted. If the item needs a different access control than the one it was created with, delete it and add it again, because that is the one case where add-or-update does not apply.

`.biometryCurrentSet` invalidates the item when the enrolled biometrics change, so an attacker who adds their own face or finger to a stolen, unlocked device gets nothing. `objection`'s own documentation notes that its bypass does not work against keychain-bound items.

Standards: MASTG-TEST-0266, MASTG-TEST-0267, MASWE-0044 (legacy MSTG-AUTH-8). `SecAccessControlCreateWithFlags` arrived with WWDC 2014 session 711.

Detection: files that call `evaluatePolicy` but never `SecAccessControlCreateWithFlags`; also confirm whether `.biometryCurrentSet` or `.biometryAny` appears at all.

```sh
grep -rl 'evaluatePolicy' --include='*.swift' . | xargs grep -L 'SecAccessControlCreateWithFlags'
```

## 4. Ignoring SecItem status codes

Severity: High. OWASP: M8 (Security Misconfiguration). Fix effort: Low.

The usual casualty is `errSecDuplicateItem` (-25299): the add fails, nobody looks, and the new value is dropped while the old one stays. Other statuses that must be handled: `errSecItemNotFound` (-25300), `errSecAuthFailed` (-25293), `errSecInteractionNotAllowed` (-25308). A cancelled authentication prompt surfaces as `errSecUserCanceled` (-128).

Insecure:

```swift
SecItemAdd(insert as CFDictionary, nil)
```

Secure:

```swift
switch SecItemAdd(insert as CFDictionary, nil) {
case errSecSuccess:
    break
case errSecDuplicateItem:
    let status = SecItemUpdate(identity as CFDictionary, [kSecValueData: payload] as CFDictionary)
    guard status == errSecSuccess else { throw KeychainFailure(status) }
case errSecInteractionNotAllowed:
    throw VaultError.deviceLocked
case errSecAuthFailed, errSecUserCanceled:
    throw VaultError.authenticationFailed
case let other:
    throw KeychainFailure(other)
}
```

`SecItemUpdate` takes two dictionaries: a search query with no `kSecValueData`, and the attributes to change. Passing the full add dictionary as the search query is a frequent mistake that makes the update match nothing.

Standards: MASTG-TEST-0300, MASTG-TEST-0301 (legacy MASVS-STORAGE-2).

Detection: files containing `SecItemAdd` with no mention of `errSecDuplicateItem` or -25299, and `SecItemAdd(` calls whose result is neither assigned nor compared.

## 5. Missing or weak data protection class

Severity: High. OWASP: M9. Fix effort: Low.

Leaving `kSecAttrAccessible` out means inheriting a default that may not fit. `kSecAttrAccessibleAlways` (deprecated in iOS 12) keeps the item decryptable while the device is locked. A class without the `ThisDeviceOnly` suffix is included in backups.

Pick deliberately:

| Item | Class |
| --- | --- |
| Passwords, access tokens used in the foreground | `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` |
| Highest-sensitivity secrets | `kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly` |
| Values background code must read (push tokens, refresh tokens) | `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` |

WWDC 2014 session 711's advice still holds: choose the most restrictive option that works for the feature.

Standards: MASTG-TEST-0299 (legacy MASTG-STORAGE-3).

Detection: `kSecAttrAccessibleAlways\b`; files with `SecItemAdd` but neither `kSecAttrAccessible` nor `kSecAttrAccessControl`; `kSecAttrAccessibleWhenUnlocked\b` without `ThisDeviceOnly`.

## 6. Reusing an AES-GCM nonce

Severity: Critical. OWASP: M10. Fix effort: Medium.

Two messages sealed with the same key and nonce share a keystream, so XOR of the ciphertexts equals XOR of the plaintexts, and the GHASH authentication key can be recovered, which lets an attacker forge messages (Joux, 2006). CryptoKit picks a fresh random nonce for you; the break only happens when code builds nonces by hand.

Insecure:

```swift
let fixedNonce = try AES.GCM.Nonce(data: Data(repeating: 0, count: 12))
let box = try AES.GCM.seal(record, using: key, nonce: fixedNonce)
```

A counter nonce that restarts from zero after each launch fails the same way.

Secure:

```swift
let key = SymmetricKey(size: .bits256)

func encrypt(_ record: Data) throws -> Data {
    guard let wire = try AES.GCM.seal(record, using: key).combined else {
        throw CryptoKitError.incorrectParameterSize
    }
    return wire
}

func decrypt(_ wire: Data) throws -> Data {
    try AES.GCM.open(try AES.GCM.SealedBox(combined: wire), using: key)
}
```

`combined` packs nonce, ciphertext and tag together, so nothing needs managing separately. This is the design goal WWDC 2019 session 709 set for CryptoKit: easy to use correctly, hard to use wrongly.

Standards: MASTG-TEST-0317, MASWE-0022 (legacy MASTG-CRYPTO-4).

Detection: `AES.GCM.Nonce(data:`, variables named like `nonce`, `Data(repeating:` with `count: 12`, and `.seal(` calls that pass `nonce:`.

## 7. MD5 or SHA-1 where security matters

Severity: High. OWASP: M10. Fix effort: Low.

MD5 collisions have been practical since 2005 (Wang and Yu); SHA-1 fell publicly with SHAttered in 2017. The January 2025 CISA guidance lists both as insecure, and CryptoKit parks them in the `Insecure` namespace for a reason.

Insecure:

```swift
let stored = Insecure.MD5.hash(data: Data(password.utf8))
```

Also `CC_MD5` and `CC_SHA1` from CommonCrypto.

Secure: use the hash that fits the job.

```swift
let fingerprint = SHA256.hash(data: download)
let tag = HMAC<SHA256>.authenticationCode(for: message, using: macKey)
```

Passwords are never hashed with a plain digest. Hash them on the server with Argon2id, bcrypt or scrypt. When a key must be derived on the device, use PBKDF2 with at least 600,000 iterations.

iOS 26 adds `SHA3_256`, `SHA3_384` and `SHA3_512`. WWDC 2025 session 314 is about post-quantum cryptography and does not cover SHA-3.

Standards: MASTG-TEST-0211, MASTG-DEMO-0015, MASTG-DEMO-0016 (legacy MSTG-CRYPTO-1).

Detection: `Insecure.MD5` or `Insecure.SHA1`, plus `CC_MD5`, `CC_SHA1` and the matching digest-length constants in `.swift` and `.m` files.

## 8. Secrets in logs

Severity: High. OWASP: M9. Fix effort: Low.

Whatever `print()`, `NSLog()` or `os_log()` emits can persist in the device log, where Xcode's console, `idevicesyslog` or `log collect --device` retrieve it; on a jailbroken device any process can read it.

`OSLogPrivacy` (iOS 14 and later) controls redaction: `.private` values are hidden in production logs, and `.sensitive` (iOS 15 and later) is always hidden.

Insecure:

```swift
print("login ok, token=\(token)")
os_log("using key %{public}@", apiKey)
```

Secure:

```swift
let authLog = Logger(subsystem: "com.example.courier", category: "auth")
authLog.info("sign-in succeeded")
authLog.debug("session \(sessionID, privacy: .private(mask: .hash))")
```

Log the event, not the value. `.private(mask: .hash)` still lets you correlate entries for the same session. With the older API, use `%{private}@`. Keep any diagnostic `print` inside `#if DEBUG`.

Standards: MASTG-TEST-0296, MASTG-TEST-0297, MASWE-0001 (legacy MSTG-STORAGE-3).

Detection: `print(` with string interpolation, `NSLog(` with `%@`, and `os_log` with `%{public}` near words like password, token, secret or key.

## 9. No keychain cleanup after reinstall

Severity: Medium. OWASP: M9. Fix effort: Low.

The keychain database lives in `securityd`, outside the app container, so items survive deleting the app. Apple DTS has described this as expected behaviour even though it has privacy consequences. The effects: stale credentials after reinstall, one user's session leaking to the next owner of a resold device, and backend SDKs failing authentication with leftover state.

Secure: before any SDK initialises, wait until protected data is readable, then check a marker in `UserDefaults`; if it is unset, delete every item class with `kSecAttrSynchronizable: kSecAttrSynchronizableAny` and set the marker.

```swift
@MainActor
func purgeKeychainOnFreshInstall() async {
    await ReinstallSweeper.waitUntilProtectedDataIsReadable()
    guard !UserDefaults.standard.bool(forKey: "vault.installMarker") else { return }
    for itemClass in [kSecClassGenericPassword, kSecClassInternetPassword,
                      kSecClassCertificate, kSecClassKey, kSecClassIdentity] {
        let scope: [CFString: Any] = [kSecClass: itemClass,
                                      kSecAttrSynchronizable: kSecAttrSynchronizableAny]
        SecItemDelete(scope as CFDictionary)
    }
    UserDefaults.standard.set(true, forKey: "vault.installMarker")
}
```

The protected-data wait is not optional. A pre-warmed launch before first unlock reads the marker as unset and would erase a signed-in user. The full version, including retry on failed deletes, is in [migration-legacy-stores.md](migration-legacy-stores.md).

Standards: MASTG-TEST-0300, MASTG-TEST-0301 (legacy MSTG-STORAGE-11).

Detection: files using `SecItemAdd` or `SecItemCopyMatching` with no first-launch marker anywhere in the project; files with `SecItemDelete` that reference such a marker are the cleanup to review.

## 10. Weak randomness for security values

Severity: High. OWASP: M10. Fix effort: Low.

The generator and the amount of entropy both matter.

- `rand()`, `random()`, `drand48()` and `srand(time(nil))` are predictable and never acceptable. GameplayKit sources such as `GKMersenneTwisterRandomSource` and `GKARC4RandomSource` are built for games and are not for security either.
- `arc4random`, `arc4random_buf` and `arc4random_uniform` are backed by the system cryptographic generator on Apple platforms, as their manual page states, and so is Swift's `SystemRandomNumberGenerator`, which `randomElement()` and `Int.random(in:)` use by default. The generator is fine; the usual defect is how little of it the code uses. `arc4random()` returns a single 32-bit `UInt32`, `arc4random() % n` adds modulo bias, and `arc4random_uniform(999_999)` produces a token with about 20 bits of entropy. A few `randomElement()` picks from a character set make an equally guessable session identifier. These functions also cannot report failure.
- For keys, tokens, salts and nonces, prefer `SecRandomCopyBytes`, which draws from the same system generator (corecrypto, seeded from hardware entropy), returns a status you can check, and makes the security intent obvious in review. For key material, use `SymmetricKey(size: .bits256)` directly.

Insecure:

```swift
let resetCode = String(arc4random_uniform(999_999))
let sessionID = String((0..<6).compactMap { _ in "abcdef0123456789".randomElement() })
```

Secure:

```swift
func makeSessionToken() throws -> String {
    var bytes = [UInt8](repeating: 0, count: 32)
    let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
    guard status == errSecSuccess else { throw KeychainFailure(status) }
    return bytes.map { String(format: "%02x", $0) }.joined()
}

let wrappingKey = SymmetricKey(size: .bits256)
```

Standards: MASTG-TEST-0311, MASTG-DEMO-0073, MASTG-DEMO-0074 (legacy MSTG-CRYPTO-6).

Detection: `srand`, `rand()`, `random()`, `drand48`, `GKARC4RandomSource`, `GKMersenneTwisterRandomSource` are findings. `arc4random` calls near token, nonce, salt, key, secret, session or iv are review prompts: check how many bits the value carries.

## Quick reference

| # | OWASP | Main MASTG test | Look for | Replace with | Effort |
| --- | --- | --- | --- | --- | --- |
| 1 | M9 | 0302 | `UserDefaults.set` of a secret | `SecItemAdd` with update on duplicate | Medium |
| 2 | M1 | 0213 | Key literals | Server proxy, runtime fetch cached in keychain | High |
| 3 | M3 | 0266 | `evaluatePolicy` as the gate | `SecAccessControlCreateWithFlags` | Medium |
| 4 | M8 | 0300 | Unchecked `SecItemAdd` | Status `switch` with update on duplicate | Low |
| 5 | M9 | 0299 | `Always`, missing class | `WhenUnlockedThisDeviceOnly` or stricter | Low |
| 6 | M10 | 0317 | `AES.GCM.Nonce(data:)` | Let CryptoKit choose the nonce | Medium |
| 7 | M10 | 0211 | `Insecure.*` | SHA-256 or stronger, a password KDF | Low |
| 8 | M9 | 0297 | `print(token)` | `Logger` with `.private` | Low |
| 9 | M9 | 0300 | No reinstall cleanup | Marker plus `SecItemDelete` after protected data | Low |
| 10 | M10 | 0311 | Low-entropy or predictable RNG | `SecRandomCopyBytes`, `SymmetricKey` | Low |

## Automating detection in CI

- **Semgrep**: fast structural rules, ideal as a pre-commit hook or pull-request gate for `UserDefaults` secrets, add calls without duplicate handling and Boolean biometric gates. Its data-flow tracking is limited.
- **CodeQL**: semantic taint analysis, for example a token flowing into a logging call. Slower; run nightly or on pull requests.
- **Binary scan after the build**: `strings` and `class-dump` over the built product catch keys that survived obfuscation or came in through configuration files.

A practical setup runs Semgrep on every pull request, a binary scan after each build, and CodeQL nightly.

## What iOS 26 changes

Prefer `.bits256` symmetric keys over `.bits128` for headroom against quantum attacks. The SHA-3 family is available from iOS 26. CryptoKit adds ML-KEM-768 and ML-KEM-1024, ML-DSA-65 and ML-DSA-87, and the X-Wing hybrid KEM, with Secure Enclave support, and `URLSession` negotiates the hybrid `X25519MLKEM768` TLS key exchange by default. Creating post-quantum keys in the Secure Enclave strengthens the fixes for entries 3 and 5.

## Checklist

- [ ] No secrets in `UserDefaults` or `@AppStorage`; keychain with `WhenUnlockedThisDeviceOnly` or stricter.
- [ ] No hardcoded keys or high-entropy literals; nothing secret in `.xcconfig` or `Info.plist`.
- [ ] Biometrics unlock a keychain item protected with `.biometryCurrentSet`.
- [ ] Every `SecItem` call checks its status; duplicates are updated, not-found is handled.
- [ ] Every add sets accessibility or access control; no `Always`; `ThisDeviceOnly` for items that should not sync.
- [ ] No explicit, global or counter nonces.
- [ ] No MD5 or SHA-1; passwords go through Argon2id, bcrypt, or PBKDF2-HMAC-SHA256 with at least 600,000 iterations.
- [ ] No secrets in logs; `%{private}@`, or `Logger` with `.private` or `.private(mask: .hash)`.
- [ ] Reinstall cleanup runs before SDK setup and after protected data is available.
- [ ] Security values come from a cryptographic generator with enough bits.
- [ ] Ready for iOS 26: `.bits256`, no deprecated algorithms, post-quantum APIs on the radar.
