AppPreflight Logo
AppPreflight
loading
Back to Guides

iOS App Security Best Practices: Protect User Data & Pass Review

AppPreflight Team
2026-06-04
10 min read

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: "&amp;")
        .replacingOccurrences(of: "<", with: "&lt;")
        .replacingOccurrences(of: ">", with: "&gt;")
        .replacingOccurrences(of: "\"", with: "&quot;")
        .replacingOccurrences(of: "'", with: "&#x27;")
}

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

  1. Audit your app for security vulnerabilities
  2. Implement HTTPS for all API calls
  3. Store sensitive data in Keychain
  4. Implement proper session management
  5. Test on real devices for security issues
  6. Use AppPreflight Pre-Review Tool to verify compliance
  7. Consider hiring security expert for review if handling sensitive data

Security-first development = App Store approval + User trust + Protected data.


Was this guide helpful?