9.7 — Secure Coding Practices in Swift

Opening scenario

Code review finds: let predicate = NSPredicate(format: "name == '\(userInput)'"). Looks harmless. But the user types '; truepredicate; -- and your search returns every record in the database. NSPredicate injection. Swift is a safer language than C, but unsafe patterns still creep in — Any, format strings, integer overflow, sensitive memory not zeroed. This chapter is the iOS-specific secure-coding checklist.

Context — what Swift gives you for free

Swift the language closes several entire classes of bugs by default:

Bug classSwift’s mitigation
Buffer overflowArray, String are bounds-checked
Use-after-freeARC + value types
Null derefOptionals + unwrap discipline
Type confusionStrong static typing, no implicit casts
Data race (under Swift 6 strict concurrency)Sendable + actor isolation

But escape hatches exist: unsafeBitCast, withUnsafePointer, Any, format strings, C interop. Each is a place where the safety net dissolves.

Input validation

Validate at trust boundaries — anywhere data enters from outside your code:

struct Username: RawRepresentable, Codable {
    let rawValue: String
    init?(rawValue: String) {
        let allowed = CharacterSet.alphanumerics.union(.init(charactersIn: "._-"))
        guard rawValue.count >= 3, rawValue.count <= 30,
              rawValue.unicodeScalars.allSatisfy(allowed.contains)
        else { return nil }
        self.rawValue = rawValue
    }
}

Push validation into types. Once a Username exists, callers know it’s safe — they can’t accidentally pass an unvalidated string. This is “make illegal states unrepresentable” applied to security boundaries.

Codable with strict decoding

JSON deserialization is a security boundary. Use strict types — never [String: Any]:

struct User: Codable {
    let id: UUID
    let email: String
    let role: Role
    enum Role: String, Codable { case admin, member, guest }
}

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
let user = try decoder.decode(User.self, from: data)

Role as an enum means a server-side value of "superadmin" causes a decode failure, not a silent privilege escalation. Avoid decodeIfPresent for security-critical fields — explicit failure is safer than silent defaulting.

NSPredicate injection

NSPredicate(format:) accepts format strings. Direct interpolation is injectable:

// ❌ Injection
let p = NSPredicate(format: "name == '\(userInput)'")

// ✅ Parameterized
let p = NSPredicate(format: "name == %@", userInput)

%@ substitutes the value as a literal, not parsed format. Same rule for NSExpression. Treat format: like printf — never concatenate untrusted input into the format.

The same applies to:

  • NSExpression(format:)
  • NSRegularExpression(pattern:) with user-supplied patterns (ReDoS risk)
  • Core Data NSFetchRequest.predicate constructed from strings

Integer overflow

Default Swift arithmetic traps on overflow (crashes the app). This is safer than C’s silent wrap, but a crash is still a DoS. For arithmetic on untrusted inputs:

// Use overflow-aware operators when overflow is acceptable
let result = a &+ b   // wraps; never traps

// Or explicit overflow check
let (result, overflow) = a.addingReportingOverflow(b)
guard !overflow else { throw ArithmeticError.overflow }

// Or use Int with bounds known to be safe

&+, &-, &* wrap silently — only use when wrap is the desired semantics (hashing, checksums). For business logic, prefer addingReportingOverflow and handle the error path.

// ❌ Trusts userInput to be a URL
UIApplication.shared.open(URL(string: userInput)!, options: [:])

// ✅ Validate scheme + host allowlist
guard let url = URL(string: userInput),
      let scheme = url.scheme?.lowercased(),
      ["https"].contains(scheme),
      let host = url.host,
      ["acme.com", "www.acme.com"].contains(host)
else { return }
UIApplication.shared.open(url)

For deeplinks into your own app, validate the path components before acting:

func handle(deeplink: URL) {
    guard deeplink.scheme == "acme",
          let components = URLComponents(url: deeplink, resolvingAgainstBaseURL: false),
          let path = components.path.split(separator: "/").first
    else { return }
    switch path {
    case "open": openItem(id: components.queryItems?.first { $0.name == "id" }?.value)
    case "share": showShareSheet()
    default: break  // never trust unknown paths
    }
}

WKWebView deserves its own audit:

  • javaScriptEnabled = false if you don’t need it
  • WKContentRuleListStore to block third-party domains
  • allowsContentJavaScript per-frame on iOS 14+
  • Never load arbitrary user-supplied HTML; always source from your own trusted backend

Logging — os_log privacy

Default os_log redacts dynamic values in release builds, replacing with <private>. Don’t override unless you mean it:

import os.log
private let log = Logger(subsystem: "com.acme.app", category: "auth")

// ✅ Default behavior — emails redacted in release logs
log.info("user signed in: \(email)")

// ❌ Forces public, leaks email to logs
log.info("user signed in: \(email, privacy: .public)")

// ✅ Explicit public for non-sensitive data
log.info("user count: \(count, privacy: .public)")

The same applies to crash reporters: filter PII before sending. Sentry, Crashlytics, and others have hooks for scrubbing breadcrumbs.

Zeroing sensitive memory

Swift Strings and Data are heap-allocated; deallocating them leaves bytes in memory pages until overwritten. For high-sensitivity data (master passwords, raw key material):

import Foundation

extension Data {
    mutating func zeroize() {
        withUnsafeMutableBytes { ptr in
            guard let base = ptr.baseAddress, ptr.count > 0 else { return }
            memset_s(base, ptr.count, 0, ptr.count)
        }
    }
}

func deriveKey(from password: String) throws -> SymmetricKey {
    var passwordData = Data(password.utf8)
    defer { passwordData.zeroize() }
    return try deriveKeyFromData(passwordData)
}

memset_s is preferred over memset because the compiler isn’t allowed to optimize it away. Note: this only helps against memory-dump attackers; it doesn’t help against jailbreak / runtime hooking.

Other Swift-specific footguns

  • Any as parameter type — defeats type checking. Use generics or protocol composition.
  • unsafeBitCast — bypasses type safety entirely. Audit every callsite; usually replaceable with proper conversion.
  • C interop (UnsafePointer, withUnsafeBytes) — restore C-level UB risk. Wrap once at the boundary, never spread.
  • Force-unwrap in user-facing flows — converts a logic bug into a crash. guard let and graceful handling.
  • open access for non-final classes shipped in libraries — allows subclassing and method override by callers; can be used to break security invariants. Use public final by default.
  • @objc dynamic — adds ObjC-runtime method dispatch, which is hookable by Frida. Audit usage in security-critical code paths.

SwiftLint security rules

Enable these in .swiftlint.yml:

opt_in_rules:
  - force_unwrapping
  - force_cast
  - explicit_init
  - implicitly_unwrapped_optional
  - private_outlet
  - prohibited_super_call

custom_rules:
  no_userdefaults_secrets:
    name: "No secrets in UserDefaults"
    regex: 'UserDefaults\.standard\.set\(.*(?:token|password|secret|key)'
    severity: error

  no_nspredicate_format_interpolation:
    name: "Don't interpolate into NSPredicate format"
    regex: 'NSPredicate\(format:\s*"[^"]*\\\('
    severity: error

These catch the most common regressions during PR review, before the security team’s quarterly scan.

In the wild

The Signal iOS codebase enforces zeroization on every key-handling code path and ships with extensive SwiftLint rules. Apple’s own first-party apps use strict Codable types throughout — leaked source from past macOS releases shows almost no [String: Any] deserialization in security-critical surfaces.

Common misconceptions

  1. “Swift is memory-safe, so secure-by-default.” Memory safety closes one class of bugs. Injection, validation, privacy, key handling are all still your responsibility.
  2. force_unwrap is just a style rule.” It’s a security rule — force-unwraps convert exploitable conditions into crashes that DoS the app.
  3. os_log redacts everything in production.” Only dynamic values, only on certain devices, and only if you don’t override with .public. Audit every log call in security-critical files.
  4. “Zeroing memory in Swift is impossible because of immutable strings.” Use Data for sensitive material from the start; never convert to String. Data.zeroize() works as shown.
  5. @objc dynamic is just for Objective-C compatibility.” It’s also the vector for Frida hooks — security-critical methods should not be @objc dynamic.

Seasoned engineer’s take

Most security bugs in modern Swift apps are not exotic memory-corruption — they’re boring validation gaps, accidental Any, force-unwraps on untrusted data, format-string injection. Build a habit of treating every trust boundary (network, file, deeplink, user input, third-party SDK callback) as adversarial: validate at the boundary, type the validated value, then trust internally. The pattern is “validate once, parse into a strong type, propagate the type.” It scales — once you can spot trust-boundary code by smell, you’ll catch security bugs in PR review automatically.

TIP: Treat [String: Any] as a code smell in any decoder. Every deserialization should land in a typed struct with Codable and enum-typed fields for closed-set values.

WARNING: Don’t disable SwiftLint security rules to silence noise — fix the underlying violations. Disabled rules grow until they’re worthless; enforced rules catch real bugs.

Interview corner

Junior: “How do you prevent NSPredicate injection?” Use %@ placeholders instead of interpolating user input into the format string. Same pattern as parameterized SQL queries.

Mid: “Walk me through input validation in Swift.” Validate at the trust boundary, then push the validated value into a strong type. Use RawRepresentable + failable init for the validation logic, so callers downstream know they’re holding a verified value without re-validating. For JSON, use strict Codable types with enum fields for closed sets, and prefer hard failures over decodeIfPresent defaults.

Senior: “What’s your secure-coding checklist for Swift code review?” At trust boundaries: validate and type. In data layer: no [String: Any], all enums for closed sets, hard-fail decode for security-critical fields. NSPredicate, NSExpression, NSRegularExpression: parameterized only. Arithmetic on untrusted data: overflow-aware operators. URLs and deeplinks: scheme + host allowlists, no naive open(URL(string:)!). Logging: default os_log redaction, audit every .public annotation. Sensitive memory: Data only, memset_s on dealloc. Force-unwraps: banned in any path reachable from untrusted input. @objc dynamic: not in security-critical methods. WKWebView: javaScriptEnabled = false unless required, content rule lists, no user-supplied HTML. SwiftLint rules enforce the boring half automatically so PR review focuses on architecture. Each item maps to a real CVE class.

Red-flag answer: “Swift handles all that automatically.” Reveals overconfidence in language features.

Lab preview

Lab 9.3 includes a Swift file with 12 deliberate secure-coding violations — NSPredicate injection, missing input validation, leaked logs, sensitive memory not zeroed. You’ll identify and fix each one.


Next: 9.8 — App Transport Security