iOS App Security Best Practices: Protect User Data & Pass Review
Publish Date: 2026-05-10
Last Updated: 2026-05-10
Author: AppPreflight Team
Overview
Security vulnerabilities are a common reason apps get rejected from the App Store. This guide covers the essential security practices required to protect user data and pass Apple's review process. Implementing these practices also demonstrates to users that their data is safe.
1. HTTPS/TLS Encryption
Why HTTPS is Mandatory
- All network communication with servers must use HTTPS
- TLS 1.2 or higher required
- Self-signed certificates not allowed
- Certificate must be valid and not expired
Implementation Requirements
App Transport Security (ATS)
Apple enforces ATS by default. This means:
<!-- In Info.plist -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/> <!-- Default: require HTTPS -->
<key>NSAllowsArbitraryLoadsForMedia</key>
<false/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<false/>
</dict>
Certificate Configuration
// Best: Let URLSession handle HTTPS automatically
let url = URL(string: "https://api.example.com/users")!
let request = URLRequest(url: url)
URLSession.shared.dataTask(with: request) { data, response, error in
// Automatically validates SSL/TLS
}.resume()
// For production APIs
var request = URLRequest(url: url)
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
Certificate Pinning (For Extra Security)
class CertificatePinning: NSObject, URLSessionDelegate {
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
// Validate certificate
var secResult = SecTrustResultType.invalid
let status = SecTrustEvaluate(serverTrust, &secResult)
if status == errSecSuccess &&
(secResult == .unspecified || secResult == .proceed) {
completionHandler(.useCredential,
URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}
HTTPS Verification
- Test all API endpoints with SSL Labs (sslabs.com)
- Ensure TLS 1.2+
- No mixed HTTP/HTTPS content
- No insecure API calls from app
2. Authentication & Session Management
Password Security
Never Store Passwords in Plaintext
// ❌ WRONG - Never do this
let savedPassword = "user123"
UserDefaults.standard.set(savedPassword, forKey: "password")
// ✅ CORRECT - Use Keychain
class KeychainManager {
static func savePassword(_ password: String, for account: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecValueData as String: password.data(using: .utf8)!
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
static func retrievePassword(for account: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecReturnData as String: true
]
var result: AnyObject?
SecItemCopyMatching(query as CFDictionary, &result)
if let data = result as? Data,
let password = String(data: data, encoding: .utf8) {
return password
}
return nil
}
}
Implement Secure Password Requirements
Password requirements:
✓ Minimum 8 characters
✓ Mix of uppercase and lowercase
✓ At least one number
✓ At least one special character (!@#$%^&*)
Never:
✗ Allow dictionary words
✗ Allow repeating characters
✗ Allow common patterns (123456, qwerty)
Password Reset Security
- Send password reset link via email (not SMS with password)
- Reset links expire after 30 minutes
- One-time use tokens (cannot be reused)
- Require email verification
- Never email passwords
Session Management
JWT Tokens (Best Practice)
class TokenManager {
static let shared = TokenManager()
private let keychainService = "com.app.tokens"
func saveAccessToken(_ token: String, expiresIn: TimeInterval) {
// Store in Keychain
let expirationDate = Date().addingTimeInterval(expiresIn)
UserDefaults.standard.set(expirationDate, forKey: "accessTokenExpiration")
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: "accessToken",
kSecValueData as String: token.data(using: .utf8)!
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
func getAccessToken() -> String? {
// Check if expired
if let expiration = UserDefaults.standard.object(forKey: "accessTokenExpiration") as? Date {
if Date() > expiration {
refreshAccessToken()
return getValidAccessToken()
}
}
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: "accessToken",
kSecReturnData as String: true
]
var result: AnyObject?
SecItemCopyMatching(query as CFDictionary, &result)
if let data = result as? Data,
let token = String(data: data, encoding: .utf8) {
return token
}
return nil
}
func logout() {
// Clear all tokens
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService
]
SecItemDelete(query as CFDictionary)
UserDefaults.standard.removeObject(forKey: "accessTokenExpiration")
}
}
Access Token Requirements
- Expires after 15-60 minutes (not days or weeks)
- Cannot be used after expiration
- Refresh tokens used to obtain new access tokens
- Refresh tokens rotate on each use
- Logout invalidates all tokens on server
Biometric Authentication
import LocalAuthentication
class BiometricAuthentication {
func authenticateWithBiometric() {
let context = LAContext()
var error: NSError?
// Check if biometric authentication is available
guard context.canEvaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
error: &error
) else {
print("Biometric not available")
return
}
// Request biometric authentication
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Authenticate to access your account"
) { success, error in
if success {
// User authenticated with Face ID or Touch ID
self.proceedWithLogin()
} else {
// Authentication failed
print("Biometric auth failed: \(error?.localizedDescription ?? "")")
}
}
}
}
3. Data Storage Security
Sensitive Data Encryption
Use Keychain for Sensitive Data
class SecureStorage {
static func saveCredential(
username: String,
password: String
) {
let credentials = "\(username):\(password)".data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.credentials",
kSecValueData as String: credentials,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
static func retriveCredential() -> (username: String, password: String)? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.app.credentials",
kSecReturnData as String: true
]
var result: AnyObject?
SecItemCopyMatching(query as CFDictionary, &result)
if let data = result as? Data,
let string = String(data: data, encoding: .utf8) {
let parts = string.split(separator: ":")
if parts.count == 2 {
return (String(parts[0]), String(parts[1]))
}
}
return nil
}
}
What Should Go in Keychain
- ✅ Passwords
- ✅ API tokens
- ✅ OAuth tokens
- ✅ Certificates
- ✅ Private keys
What NOT to Store Insecurely
- ❌ User email (but can store in UserDefaults if not sensitive)
- ❌ PII without encryption
- ❌ API keys in source code
- ❌ Hardcoded credentials
Local Database Encryption
CoreData with SQLCipher
import CoreData
class DatabaseManager {
static let shared = DatabaseManager()
lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: "MyApp")
let description = NSPersistentStoreDescription()
description.url = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).last?.appendingPathComponent("MyApp.sqlite")
// Enable encryption
description.setOption(NSNumber(value: 1), forKey: "SQLCipher")
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { _, error in
if let error = error {
fatalError("Database error: \(error)")
}
}
return container
}()
}
Realm Database with Encryption
import RealmSwift
let configuration = Realm.Configuration(
encryptionKey: getEncryptionKey() // 64-byte key
)
do {
let realm = try Realm(configuration: configuration)
// Use encrypted realm
} catch {
print("Failed to open realm: \(error)")
}
func getEncryptionKey() -> Data {
// Retrieve or generate encryption key
// Store key securely in Keychain
return Data(count: 64)
}
File Encryption
class FileManager {
static func saveSecureFile(
_ data: Data,
filename: String
) throws {
let fileURL = FileManager.default.urls(
for: .documentDirectory,
in: .userDomainMask
).first?.appendingPathComponent(filename)
// Set file protection attributes
try data.write(to: fileURL!)
try FileManager.default.setAttributes(
[FileAttributeKey.protectionKey: FileProtectionType.complete],
ofItemAtPath: fileURL!.path
)
}
}
4. Input Validation & Injection Prevention
SQL Injection Prevention
// ❌ VULNERABLE - Never do this
let query = "SELECT * FROM users WHERE email = '\(userInput)'"
// ✅ SAFE - Use parameterized queries
let query = "SELECT * FROM users WHERE email = ?"
let statement = try db.prepare(query)
let rows = try statement.bind(userInput)
Cross-Site Scripting (XSS) Prevention
// ❌ VULNERABLE
let html = "<html><body>\(userInput)</body></html>"
// ✅ SAFE - Escape HTML entities
func escapeHTML(_ string: String) -> String {
return string
.replacingOccurrences(of: "&", with: "&")
.replacingOccurrences(of: "<", with: "<")
.replacingOccurrences(of: ">", with: ">")
.replacingOccurrences(of: "\"", with: """)
.replacingOccurrences(of: "'", with: "'")
}
let html = "<html><body>\(escapeHTML(userInput))</body></html>"
Input Validation
class InputValidator {
static func validateEmail(_ email: String) -> Bool {
let pattern = "^[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}$"
let regex = try! NSRegularExpression(pattern: pattern)
return regex.firstMatch(in: email, range: NSRange(email.startIndex..., in: email)) != nil
}
static func validateURL(_ url: String) -> Bool {
guard let url = URL(string: url) else { return false }
return UIApplication.shared.canOpenURL(url)
}
static func validatePhoneNumber(_ phone: String) -> Bool {
let pattern = "^[\\d\\s\\-\\(\\)\\+]{10,}$"
let regex = try! NSRegularExpression(pattern: pattern)
return regex.firstMatch(in: phone, range: NSRange(phone.startIndex..., in: phone)) != nil
}
}
5. API Security
Secure API Communication
API Key Management
// ❌ NEVER store API key in source code
let apiKey = "sk-1234567890abcdef"
// ✅ CORRECT - Fetch from backend
class APIKeyManager {
static func fetchAPIKey(completion: @escaping (String) -> Void) {
let url = URL(string: "https://api.example.com/api-key")!
URLSession.shared.dataTask(with: url) { data, _, _ in
if let data = data,
let response = try? JSONDecoder().decode(APIKeyResponse.self, from: data) {
completion(response.apiKey)
}
}.resume()
}
}
// ✅ BETTER - Use backend proxy
// Client calls: https://api.example.com/some-endpoint
// Backend internally calls: https://third-party.com/api with API key
// API key never exposed to client
Request Rate Limiting
class RateLimiter {
private var requestTimes: [Date] = []
private let maxRequests = 10
private let timeWindow: TimeInterval = 60
func canMakeRequest() -> Bool {
let now = Date()
requestTimes = requestTimes.filter { now.timeIntervalSince($0) < timeWindow }
if requestTimes.count < maxRequests {
requestTimes.append(now)
return true
}
return false
}
}
6. Code Obfuscation & Protection
Disable Debug Info in Production
// In Build Settings:
// Debug Information Format: DWARF (not DWARF with dSYM)
// Strip Linked Product: YES
// Strip Style: All Symbols
Remove Hardcoded Secrets
// ❌ WRONG
let databaseURL = "postgres://admin:password123@db.example.com/myapp"
// ✅ CORRECT
let databaseURL = getSecureConfigValue("DATABASE_URL")
func getSecureConfigValue(_ key: String) -> String? {
// Fetch from secure backend configuration service
return nil
}
Prevent Reverse Engineering
// Basic jailbreak detection (optional)
func isDeviceJailbroken() -> Bool {
let fileManager = FileManager.default
let jailbreakPaths = [
"/Applications/Cydia.app",
"/Library/MobileSubstrate/DynamicLibraries/",
"/bin/bash",
"/usr/sbin/sshd"
]
for path in jailbreakPaths {
if fileManager.fileExists(atPath: path) {
return true
}
}
return false
}
Security Verification Checklist
Before submission to App Store:
- All network communication uses HTTPS/TLS 1.2+
- No hardcoded passwords or API keys
- Passwords stored in Keychain, not UserDefaults
- Sensitive data encrypted at rest
- Sessions invalidated on logout
- No unencrypted PII in logs
- Input validation on all user input
- No SQL injection vulnerabilities
- Rate limiting implemented
- Certificate pinning for critical APIs
- Biometric authentication supported (if applicable)
- No debug symbols in production build
- OWASP Top 10 Mobile risks addressed
- Third-party dependencies updated
- Security testing completed
Next Steps
- Audit your app for security vulnerabilities
- Implement HTTPS for all API calls
- Store sensitive data in Keychain
- Implement proper session management
- Test on real devices for security issues
- Use AppPreflight Pre-Review Tool to verify compliance
- Consider hiring security expert for review if handling sensitive data
Security-first development = App Store approval + User trust + Protected data.