04 · Security Best Practices¶
Swift ships CryptoKit, a modern, misuse-resistant cryptography API, as
part of the standard toolchain — no external dependency needed. This
module covers hashing, message authentication, symmetric encryption, and a
few non-cryptographic habits (constant-time comparison, input validation)
that matter just as much in practice.
Hashing with SHA-256¶
Hashing is one-way: it produces a fixed-size fingerprint of data, useful for integrity checks, but never for storing passwords directly (see below):
import CryptoKit
import Foundation
let password = "correct horse battery staple"
let digest = SHA256.hash(data: Data(password.utf8))
let hex = digest.map { String(format: "%02x", $0) }.joined()
print("SHA256:", hex)
Output:
Never hash passwords with a plain, fast hash like SHA-256 for storage.
Password storage needs a slow, salted, purpose-built algorithm (bcrypt,
scrypt, or Argon2) specifically because it must resist brute-force
guessing — SHA-256 is fast, which is exactly the wrong property for that
job. SHA256 here is for data-integrity fingerprints, not credentials.
HMAC: verifying a message hasn't been tampered with¶
An HMAC combines a hash function with a secret key, proving both that data is unmodified and that it was produced by someone who knows the key:
let key = SymmetricKey(size: .bits256)
let message = "transfer $100 to Bob".data(using: .utf8)!
let hmac = HMAC<SHA256>.authenticationCode(for: message, using: key)
print("HMAC verifies:", HMAC<SHA256>.isValidAuthenticationCode(hmac, authenticating: message, using: key))
Output:
This is the mechanism behind signed URLs, webhook signature verification, and API request signing — the sender computes an HMAC over the payload with a shared secret, and the receiver recomputes it independently to confirm nothing changed in transit.
Symmetric encryption with AES-GCM¶
AES.GCM is authenticated encryption — it protects both confidentiality
(the data is unreadable without the key) and integrity (tampering is
detected) in one primitive:
let secretKey = SymmetricKey(size: .bits256)
let plaintext = "sensitive data".data(using: .utf8)!
let sealedBox = try! AES.GCM.seal(plaintext, using: secretKey)
let ciphertext = sealedBox.combined!
print("Encrypted length:", ciphertext.count, "original length:", plaintext.count)
let opened = try! AES.GCM.open(AES.GCM.SealedBox(combined: ciphertext), using: secretKey)
print("Decrypted:", String(data: opened, encoding: .utf8)!)
Output:
The ciphertext is longer than the plaintext because combined bundles the
nonce and the authentication tag alongside the actual encrypted bytes —
all three are needed to decrypt and verify, so combined is the form you
should store or transmit as a single unit.
Constant-time comparison¶
Comparing secrets (tokens, MACs) with == can leak timing information —
== on strings typically short-circuits at the first mismatched byte,
and an attacker measuring response times can exploit that to guess a
secret one byte at a time:
func constantTimeEquals(_ a: String, _ b: String) -> Bool {
let aBytes = Array(a.utf8)
let bBytes = Array(b.utf8)
guard aBytes.count == bBytes.count else { return false }
var diff: UInt8 = 0
for i in 0..<aBytes.count { diff |= aBytes[i] ^ bBytes[i] }
return diff == 0
}
print("Constant-time compare (equal):", constantTimeEquals("secret-token", "secret-token"))
print("Constant-time compare (different):", constantTimeEquals("secret-token", "wrong-token!"))
Output:
The loop always runs over every byte regardless of where a mismatch
occurs, so its execution time doesn't reveal where the strings first
differ — CryptoKit's own HMAC.isValidAuthenticationCode above already
does this internally, so prefer it over hand-rolled comparisons whenever a
CryptoKit API is verifying its own output.
Input validation¶
Never trust data crossing a trust boundary (user input, request bodies, query parameters) without validating its shape first:
func isValidEmail(_ email: String) -> Bool {
let pattern = #"^[^\s@]+@[^\s@]+\.[^\s@]+$"#
return email.range(of: pattern, options: .regularExpression) != nil
}
print("Valid email:", isValidEmail("user@example.com"))
print("Invalid email:", isValidEmail("not-an-email"))
Output:
This pattern is intentionally loose — real email validation is famously hard to get exactly right with a single regex — but rejecting obviously malformed input early, before it reaches a database query or an external API call, closes off a large class of injection and malformed-data bugs cheaply.
Storing secrets: Keychain, not UserDefaults¶
On Apple platforms, UserDefaults is a plist file with no encryption at
rest — appropriate for preferences, never for tokens, passwords, or API
keys. The Keychain (via Security.framework, or a wrapper like
KeychainAccess) is the correct store, encrypted and access-controlled by
the OS. Keychain APIs need a full Xcode/simulator or device environment to
exercise meaningfully, so they aren't demonstrated with runnable output
here — but the rule (Keychain for secrets, UserDefaults for everything
else) applies regardless of platform availability.
Swift-specific traps¶
try!on cryptographic operations (as used above for brevity) is a real crash risk in production —AES.GCM.seal/.opencan throw (a corrupted or tampered ciphertext, a wrong key), and production code should handle that withtry/catch, surfacing a generic "decryption failed" rather than crashing or leaking why it failed.SymmetricKey(size:)generates a new random key every time — it is not deterministic and not derived from any string you might expect; deriving a key from a password requires a key-derivation function like HKDF (also inCryptoKit), not a rawSymmetricKeyinitializer.- A
SealedBox'scombinedproperty is optional (nilin some non-default nonce configurations) — force-unwrapping it, as this module does for brevity, should become a properguard letin real code. - Regex-based validation is a first filter, not a security boundary on its own — always pair input validation with parameterized queries (Module 03, Level 3) and proper output encoding; validation alone doesn't prevent injection if the validated string still reaches an unsafe sink.
Cheat sheet¶
| Need | CryptoKit API |
|---|---|
| Data integrity fingerprint | SHA256.hash(data:) |
| Verify a message + shared secret | HMAC<SHA256>.authenticationCode / .isValidAuthenticationCode |
| Encrypt + authenticate data | AES.GCM.seal / .open |
| Compare secrets safely | HMAC.isValidAuthenticationCode, or a manual constant-time XOR loop |
| Store a secret on-device | Keychain (never UserDefaults) |
How It Actually Works¶
- Keychain storage isn't "just an encrypted file you read/write" — it's a
separate system daemon (
securityd/SecItemon Apple platforms) that your process talks to via IPC (XPC). Each item is stored encrypted with a key derived from the device's hardware Secure Enclave (where available) and the device passcode, and access-control flags (kSecAttrAccessible*) determine when the OS is even willing to decrypt the item for you (e.g.whenUnlockedThisDeviceOnlyrefuses access while the device is locked, enforced by the OS, not by your app's code). - ATS (App Transport Security) enforcement happens at the
URLSessionlayer before a single byte goes over the network — the OS checks the target host's TLS configuration against ATS's minimum requirements (TLS version, forward secrecy cipher suites) and refuses the connection outright if it doesn't qualify, which is why ATS exceptions are declared inInfo.plistrather than checked in your Swift code — the enforcement point is beneath your code, in the platform's networking stack. - Certificate pinning works by intercepting
URLSessionDelegate'sdidReceiveChallengecallback (fired mid-TLS-handshake, before the request body is sent) and comparing the server's presented certificate/public key against a bundled expected value — this happens in addition to, not instead of, normal system trust-chain validation, so pinning failures are a separate rejection path from "invalid certificate." - String literals containing secrets are visible in the compiled binary as
plain readable bytes in the
__TEXTsegment (Swift string literals aren't obfuscated by default) — this is a mechanical fact worth internalizing: a hardcoded API key isn't "hidden" by compilation, it's trivially extractable withstringson the binary, which is precisely why secrets belong in Keychain or a remote config service instead.
Exercise¶
Write a function func sign(payload: String, secret: SymmetricKey) ->
String that computes an HMAC-SHA256 over the payload and returns it
hex-encoded (reuse the hex-encoding pattern from the SHA-256 example).
Write a matching func verify(payload: String, signature: String, secret:
SymmetricKey) -> Bool that recomputes the signature and compares it to the
provided one using HMAC.isValidAuthenticationCode (not a plain == on
the hex strings). Demonstrate that tampering with even one character of
payload after signing causes verify to return false.