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 class | Swift’s mitigation |
|---|---|
| Buffer overflow | Array, String are bounds-checked |
| Use-after-free | ARC + value types |
| Null deref | Optionals + unwrap discipline |
| Type confusion | Strong 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.predicateconstructed 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.
URLs, web views, and deeplinks
// ❌ 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 = falseif you don’t need itWKContentRuleListStoreto block third-party domainsallowsContentJavaScriptper-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
Anyas 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 letand graceful handling. openaccess for non-final classes shipped in libraries — allows subclassing and method override by callers; can be used to break security invariants. Usepublic finalby 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
- “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.
- “
force_unwrapis just a style rule.” It’s a security rule — force-unwraps convert exploitable conditions into crashes that DoS the app. - “
os_logredacts 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. - “Zeroing memory in Swift is impossible because of immutable strings.” Use
Datafor sensitive material from the start; never convert toString.Data.zeroize()works as shown. - “
@objc dynamicis 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 withCodableand 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.