- Security Fundamentals: What Never Belongs in Code
- Secure Storage Implementation
- Network Security Implementation
- Code Obfuscation Implementation
- UI Security Implementation
- Runtime Protection Implementation
- Biometric Authentication Implementation
- A Real Implementation Story: Testing Saves Production
- Implementation Checklist
- Conclusion
Security architecture provides the blueprint, but implementation determines whether your mobile application actually protects user data. A perfectly designed security model fails if the code contains vulnerabilities, uses weak cryptography, or mishandles sensitive data. The gap between security theory and secure code is where most breaches occur.
Mobile platforms provide robust security APIs, but using them correctly requires understanding their nuances. iOS Keychain and Android Keystore offer hardware-backed encryption, but misconfigured accessibility settings can expose data. Certificate pinning strengthens network security, but improper implementation breaks applications during certificate rotation. Root detection identifies compromised devices, but naive checks are easily bypassed.
This exploration focuses on implementation—the actual code that secures mobile applications. We’ll examine secure storage patterns, network security implementations, code obfuscation techniques, runtime protection mechanisms, and authentication flows. Each section provides working code you can adapt, along with the pitfalls that turn security features into vulnerabilities.
Security Fundamentals: What Never Belongs in Code
Before implementing security features, understand what should never appear in your codebase. These mistakes are common, easily exploited, and completely avoidable.
Hardcoded Secrets: The Cardinal Sin
Hardcoding sensitive data in source code is the most common and dangerous security mistake. Once committed to version control, secrets remain in repository history forever—even after deletion.
🚫 Never Hardcode These in Code
Credentials and Keys
- Passwords and passphrases
- API keys and secrets
- Private keys and certificates
- Database credentials
- OAuth client secrets
- Encryption keys
Sensitive Configuration
- Production server URLs with embedded credentials
- Third-party service tokens
- Signing keys
- Webhook secrets
- Service account credentials
Personal Information
- User data in test code
- Email addresses
- Phone numbers
- Any PII used for testing
Why Hardcoded Secrets Are Dangerous
Source code is not secure storage. Developers share repositories, CI/CD systems access code, decompilers extract strings from binaries, and version control preserves history indefinitely.
// ❌ NEVER DO THIS
class ApiClient {
companion object {
private const val API_KEY = "sk_live_51H7xK2eZvKYlo2C..." // Exposed!
private const val SECRET = "whsec_abc123..." // In version control!
private const val DB_PASSWORD = "MyP@ssw0rd123" // Visible to all devs!
}
}
// ❌ NEVER DO THIS
class Configuration {
static let apiKey = "AIzaSyD-9tSrke72PouQMnMX-a7eZSW0jkFMBWY" // Exposed!
static let privateKey = "-----BEGIN PRIVATE KEY-----\nMIIE..." // Disaster!
}
Anyone with repository access sees these secrets. Decompiling the app reveals them. Attackers search GitHub for exposed keys. Once leaked, secrets must be rotated immediately—assuming you discover the leak.
The Right Way: Environment-Based Configuration
Secrets belong in secure storage, loaded at runtime, never committed to version control.
// ✅ Load from secure storage
class ApiClient(context: Context) {
private val secureStorage = SecureStorage(context)
fun getApiKey(): String? {
// Load from encrypted storage, not hardcoded
return secureStorage.loadToken("api_key")
}
}
// ✅ Load from Keychain
class Configuration {
static func getApiKey() -> String? {
return SecureStorage.loadToken(forKey: "api_key")
}
}
Configuration Management Strategy
Different types of configuration require different approaches:
🔧 Configuration Best Practices
Public Configuration (Safe to Commit)
- Feature flags
- UI configuration
- Non-sensitive URLs
- Timeout values
- Cache sizes
Environment-Specific (Build-Time Injection)
- Server endpoints (without credentials)
- Environment identifiers
- Debug flags
- Analytics IDs (non-sensitive)
Secrets (Runtime Loading Only)
- API keys and tokens
- Encryption keys
- Credentials
- Private keys
- Service secrets
Build-Time Secret Injection
For secrets needed at build time, inject them from environment variables or secure build systems—never commit them.
// Android: build.gradle
android {
defaultConfig {
// Load from environment, not hardcoded
buildConfigField "String", "API_KEY", "\"${System.getenv('API_KEY') ?: ''}\""
}
}
# iOS: Use xcconfig files (not committed)
# Config.xcconfig
API_KEY = ${API_KEY}
# .gitignore
Config.xcconfig
Provide template files showing structure without actual secrets:
// Config.template.xcconfig (committed)
// Copy to Config.xcconfig and fill in actual values
API_KEY = YOUR_API_KEY_HERE
Logging Sensitive Data: Silent Exposure
Logging sensitive data exposes it to anyone with log access—developers, support staff, crash reporting services, and attackers who gain device access.
// ❌ NEVER DO THIS
fun login(username: String, password: String) {
Log.d("Auth", "Login attempt: $username / $password") // Exposed in logs!
// ...
}
fun processPayment(cardNumber: String, cvv: String) {
Log.d("Payment", "Processing card: $cardNumber, CVV: $cvv") // PCI violation!
// ...
}
// ❌ NEVER DO THIS
func authenticate(token: String) {
print("Auth token: \(token)") // Visible in console!
// ...
}
Logs persist in system logs, crash reports, and analytics platforms. Remove sensitive logging from production builds:
# ProGuard: Remove logging in release
-assumenosideeffects class android.util.Log {
public static *** d(...);
public static *** v(...);
public static *** i(...);
}
Version Control Hygiene
Once committed, secrets remain in repository history. Prevention is critical:
⚠️ Version Control Security
Before Committing
- Review diffs for secrets
- Use pre-commit hooks to scan for secrets
- Maintain .gitignore for config files
- Use secret scanning tools
If Secrets Are Committed
- Rotate compromised secrets immediately
- Don't just delete—history preserves them
- Use tools like BFG Repo-Cleaner to purge history
- Notify security team
Prevention Tools
- git-secrets (AWS)
- detect-secrets (Yelp)
- GitHub secret scanning
- GitGuardian
API Keys in Mobile Apps: Special Considerations
Mobile apps are distributed to users who can extract any embedded data. Even obfuscated or encrypted keys can be extracted by determined attackers.
🔑 API Key Strategy for Mobile
Client-Side API Keys
- Assume they will be extracted
- Use keys with minimal permissions
- Implement rate limiting server-side
- Monitor for abuse
- Rotate regularly
Server-Side Proxy Pattern
- Keep sensitive keys on server
- Mobile app calls your API
- Your server calls third-party APIs
- Authenticate mobile requests
- Control access server-side
When Client-Side Keys Are Necessary
- Use platform-specific restrictions (iOS bundle ID, Android package name)
- Implement certificate pinning
- Add request signing
- Monitor usage patterns
- Have rotation procedures ready
The server-side proxy pattern is always more secure than embedding keys in mobile apps, even with obfuscation.
Secure Storage Implementation
Platform-provided secure storage mechanisms are your first line of defense. Never implement custom encryption when iOS Keychain or Android Keystore are available.
When to Use Secure Storage
Secure storage is for runtime secrets—data obtained after app installation through authentication or API calls. Never use it to hide hardcoded secrets that should never be in the app.
✅ Appropriate Uses of Secure Storage
Runtime Secrets
- Authentication tokens received after login
- Session keys from server
- User credentials (if absolutely necessary)
- Temporary encryption keys
- OAuth tokens
Not for Build-Time Secrets
- Don't store hardcoded API keys in Keychain
- Don't encrypt hardcoded secrets and store them
- Don't use secure storage to hide what shouldn't be there
- Server-side secrets should never reach the client
iOS Keychain: Complete Implementation
import Security
class SecureStorage {
static func saveToken(_ token: String, forKey key: String) -> Bool {
guard let data = token.data(using: .utf8) else { return false }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
static func loadToken(forKey key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let token = String(data: data, encoding: .utf8) else {
return nil
}
return token
}
static func deleteToken(forKey key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess
}
}
🔑 Keychain Accessibility Levels
kSecAttrAccessibleWhenUnlockedThisDeviceOnly
- Most secure for sensitive data
- Not backed up to iCloud
- Accessible only when device unlocked
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
- For background tasks
- Available after first unlock
- Not backed up
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
- Requires device passcode
- Deleted if passcode removed
- Maximum security
Android Secure Storage: EncryptedSharedPreferences
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
class SecureStorage(private val context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val encryptedPrefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveToken(token: String) {
encryptedPrefs.edit().putString("auth_token", token).apply()
}
fun loadToken(): String? {
return encryptedPrefs.getString("auth_token", null)
}
fun deleteToken() {
encryptedPrefs.edit().remove("auth_token").apply()
}
}
Android Keystore for Cryptographic Keys
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
class KeystoreManager {
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply {
load(null)
}
fun generateKey(alias: String): SecretKey {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore"
)
val keySpec = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(false)
.build()
keyGenerator.init(keySpec)
return keyGenerator.generateKey()
}
fun getKey(alias: String): SecretKey? {
return keyStore.getKey(alias, null) as? SecretKey
}
}
⚠️ Storage Anti-Patterns
Never Do This
- Store passwords in UserDefaults/SharedPreferences
- Hardcode encryption keys in code
- Use weak algorithms (DES, MD5, SHA1)
- Log sensitive data to console
- Store API keys in version control
Network Security Implementation
HTTPS alone is insufficient. Proper TLS configuration, certificate validation, and request signing provide defense in depth.
iOS TLS Configuration with Certificate Validation
class SecureNetworking {
private lazy var session: URLSession = {
let config = URLSessionConfiguration.default
config.tlsMinimumSupportedProtocolVersion = .TLSv12
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
func makeRequest(url: URL, completion: @escaping (Data?, Error?) -> Void) {
let task = session.dataTask(with: url) { data, response, error in
completion(data, error)
}
task.resume()
}
}
extension SecureNetworking: URLSessionDelegate {
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
var secResult = SecTrustResultType.invalid
let status = SecTrustEvaluate(serverTrust, &secResult)
if status == errSecSuccess &&
(secResult == .unspecified || secResult == .proceed) {
let credential = URLCredential(trust: serverTrust)
completionHandler(.useCredential, credential)
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}
Android Network Security Configuration
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<debug-overrides>
<trust-anchors>
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>
<application
android:networkSecurityConfig="@xml/network_security_config">
</application>
Request Signing Implementation
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import java.util.Base64
class RequestSigner(private val secretKey: String) {
fun signRequest(method: String, path: String, body: String, timestamp: Long): String {
val payload = "$method\n$path\n$body\n$timestamp"
val mac = Mac.getInstance("HmacSHA256")
val keySpec = SecretKeySpec(secretKey.toByteArray(), "HmacSHA256")
mac.init(keySpec)
val signature = mac.doFinal(payload.toByteArray())
return Base64.getEncoder().encodeToString(signature)
}
fun createHeaders(method: String, path: String, body: String): Map<String, String> {
val timestamp = System.currentTimeMillis()
val signature = signRequest(method, path, body, timestamp)
return mapOf(
"X-Timestamp" to timestamp.toString(),
"X-Signature" to signature
)
}
}
Server-side verification:
import hmac
import hashlib
import time
import base64
def verify_signature(method, path, body, timestamp, signature, secret_key):
current_time = int(time.time() * 1000)
if abs(current_time - int(timestamp)) > 300000: # 5 minutes
return False
payload = f"{method}\n{path}\n{body}\n{timestamp}"
expected_signature = hmac.new(
secret_key.encode(),
payload.encode(),
hashlib.sha256
).digest()
return hmac.compare_digest(
expected_signature,
base64.b64decode(signature)
)
🚫 Network Security Mistakes
Never Disable Certificate Validation
- Don't trust all certificates in production
- Don't ignore SSL errors
- Don't allow cleartext HTTP traffic
Enforce Strong TLS
- Minimum TLS 1.2
- Avoid weak cipher suites
- Use platform security configurations
Code Obfuscation Implementation
Obfuscation raises the bar for reverse engineering but isn’t foolproof. Combine with server-side validation.
Android ProGuard Configuration
// app/build.gradle
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'),
'proguard-rules.pro'
}
}
}
# proguard-rules.pro
-keep class com.example.app.Application { *; }
-repackageclasses ''
-allowaccessmodification
# Remove logging
-assumenosideeffects class android.util.Log {
public static *** d(...);
public static *** v(...);
public static *** i(...);
}
-keepclasseswithmembernames class * {
native <methods>;
}
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
String Obfuscation
object StringObfuscator {
private const val KEY = 0x5A
fun obfuscate(input: String): ByteArray {
return input.toByteArray().map { (it.toInt() xor KEY).toByte() }.toByteArray()
}
fun deobfuscate(input: ByteArray): String {
return input.map { (it.toInt() xor KEY).toByte() }.toByteArray().toString(Charsets.UTF_8)
}
}
class ApiConfig {
companion object {
private val API_KEY_OBFUSCATED = byteArrayOf(
0x3e, 0x2f, 0x3a, 0x2b, 0x3c, 0x2d, 0x3e, 0x2f
)
fun getApiKey(): String {
return StringObfuscator.deobfuscate(API_KEY_OBFUSCATED)
}
}
}
🛡️ Obfuscation Best Practices
What to Obfuscate
- Business logic and algorithms
- API endpoints and parameters
- Internal class and method names
What to Keep
- Public API interfaces
- Reflection-based classes
- Native method declarations
- Serialization classes
Testing
- Test release builds thoroughly
- Verify crash reports are readable
- Use mapping files for deobfuscation
UI Security Implementation
Protect sensitive data from visual capture through screenshots and app switcher previews.
Android Screen Capture Prevention
import android.view.WindowManager
import androidx.appcompat.app.AppCompatActivity
class SecureActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Prevent screenshots and screen recording
window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
setContentView(R.layout.activity_secure)
}
}
iOS Screenshot Detection
import UIKit
class SecureViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// Detect when screenshot is taken
NotificationCenter.default.addObserver(
self,
selector: #selector(screenshotTaken),
name: UIApplication.userDidTakeScreenshotNotification,
object: nil
)
}
@objc private func screenshotTaken() {
// Log event or warn user
print("Screenshot detected")
// Could also blur sensitive content temporarily
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
App Switcher Protection - iOS
import UIKit
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private var blurView: UIVisualEffectView?
func applicationWillResignActive(_ application: UIApplication) {
// Hide sensitive content before snapshot
addBlurEffect()
}
func applicationDidBecomeActive(_ application: UIApplication) {
// Restore content when app returns
removeBlurEffect()
}
private func addBlurEffect() {
guard let window = window, blurView == nil else { return }
let blurEffect = UIBlurEffect(style: .light)
let blurView = UIVisualEffectView(effect: blurEffect)
blurView.frame = window.bounds
blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
window.addSubview(blurView)
self.blurView = blurView
}
private func removeBlurEffect() {
blurView?.removeFromSuperview()
blurView = nil
}
}
Alternative approach using a placeholder view:
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private var coverView: UIView?
func applicationWillResignActive(_ application: UIApplication) {
addCoverView()
}
func applicationDidBecomeActive(_ application: UIApplication) {
removeCoverView()
}
private func addCoverView() {
guard let window = window, coverView == nil else { return }
let cover = UIView(frame: window.bounds)
cover.backgroundColor = .white
// Optional: Add app logo
let imageView = UIImageView(image: UIImage(named: "logo"))
imageView.contentMode = .scaleAspectFit
imageView.frame = CGRect(x: 0, y: 0, width: 100, height: 100)
imageView.center = cover.center
cover.addSubview(imageView)
window.addSubview(cover)
self.coverView = cover
}
private func removeCoverView() {
coverView?.removeFromSuperview()
coverView = nil
}
}
App Switcher Protection - Android
import android.app.Activity
import android.os.Bundle
import android.view.View
import android.widget.ImageView
class MainActivity : AppCompatActivity() {
private var coverView: View? = null
override fun onPause() {
super.onPause()
// Hide content before app switcher snapshot
showCoverView()
}
override fun onResume() {
super.onResume()
// Restore content when app returns
hideCoverView()
}
private fun showCoverView() {
if (coverView == null) {
coverView = layoutInflater.inflate(R.layout.cover_screen, null)
addContentView(
coverView,
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
}
coverView?.visibility = View.VISIBLE
}
private fun hideCoverView() {
coverView?.visibility = View.GONE
}
}
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white">
<ImageView
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_centerInParent="true"
android:src="@drawable/logo"
android:contentDescription="@string/app_logo" />
</RelativeLayout>
🛡️ UI Security Best Practices
Screen Capture Prevention
- Apply FLAG_SECURE to sensitive activities only
- Don't prevent capture on all screens
- Consider user needs for legitimate screenshots
- Test with screen recording apps
App Switcher Protection
- Apply cover immediately in onPause/willResignActive
- Remove cover in onResume/didBecomeActive
- Use simple, fast-loading cover views
- Test rapid app switching scenarios
Performance Considerations
- Keep cover views lightweight
- Avoid complex layouts or animations
- Cache cover views for reuse
- Test on low-end devices
Runtime Protection Implementation
Detect compromised environments and respond appropriately.
iOS Jailbreak Detection
class JailbreakDetector {
static func isJailbroken() -> Bool {
let jailbreakPaths = [
"/Applications/Cydia.app",
"/Library/MobileSubstrate/MobileSubstrate.dylib",
"/bin/bash",
"/usr/sbin/sshd",
"/etc/apt",
"/private/var/lib/apt/"
]
for path in jailbreakPaths {
if FileManager.default.fileExists(atPath: path) {
return true
}
}
let testPath = "/private/jailbreak_test.txt"
do {
try "test".write(toFile: testPath, atomically: true, encoding: .utf8)
try FileManager.default.removeItem(atPath: testPath)
return true
} catch {
// Cannot write outside sandbox
}
if let url = URL(string: "cydia://package/com.example.package"),
UIApplication.shared.canOpenURL(url) {
return true
}
return false
}
}
Android Root Detection
class RootDetector(private val context: Context) {
fun isRooted(): Boolean {
return checkBuildTags() ||
checkSuperuserApk() ||
checkSuBinary() ||
checkRootManagementApps()
}
private fun checkBuildTags(): Boolean {
val buildTags = android.os.Build.TAGS
return buildTags != null && buildTags.contains("test-keys")
}
private fun checkSuperuserApk(): Boolean {
return try {
context.packageManager.getPackageInfo("com.noshufou.android.su", 0)
true
} catch (e: Exception) {
false
}
}
private fun checkSuBinary(): Boolean {
val paths = arrayOf(
"/system/app/Superuser.apk",
"/sbin/su",
"/system/bin/su",
"/system/xbin/su",
"/data/local/xbin/su",
"/data/local/bin/su",
"/system/sd/xbin/su",
"/system/bin/failsafe/su",
"/data/local/su"
)
return paths.any { File(it).exists() }
}
private fun checkRootManagementApps(): Boolean {
val packages = arrayOf(
"com.topjohnwu.magisk",
"eu.chainfire.supersu",
"com.koushikdutta.superuser"
)
return packages.any { packageName ->
try {
context.packageManager.getPackageInfo(packageName, 0)
true
} catch (e: Exception) {
false
}
}
}
}
Debugger Detection
// iOS
func isDebuggerAttached() -> Bool {
var info = kinfo_proc()
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
var size = MemoryLayout<kinfo_proc>.stride
let result = sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0)
return (result == 0) && ((info.kp_proc.p_flag & P_TRACED) != 0)
}
// Android
fun isDebuggerConnected(): Boolean {
return Debug.isDebuggerConnected() || Debug.waitingForDebugger()
}
fun isDebuggable(context: Context): Boolean {
return (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0
}
🔍 Response Strategies
Graceful Degradation
- Disable sensitive features
- Show warning to user
- Limit functionality
Silent Monitoring
- Log to analytics
- Server-side risk scoring
- Pattern detection
Hard Blocking
- Refuse to run
- High-security apps only
- Clear user explanation
Biometric Authentication Implementation
// iOS Face ID / Touch ID
import LocalAuthentication
class BiometricAuth {
func authenticate(completion: @escaping (Bool, Error?) -> Void) {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
completion(false, error)
return
}
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Authenticate to access your account"
) { success, error in
DispatchQueue.main.async {
completion(success, error)
}
}
}
}
// Android Biometric
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
class BiometricAuth(private val activity: FragmentActivity) {
fun authenticate(
onSuccess: () -> Unit,
onError: (String) -> Unit
) {
val executor = ContextCompat.getMainExecutor(activity)
val biometricPrompt = BiometricPrompt(
activity,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) {
onSuccess()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
onError(errString.toString())
}
override fun onAuthenticationFailed() {
onError("Authentication failed")
}
}
)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Biometric Authentication")
.setSubtitle("Authenticate to access your account")
.setNegativeButtonText("Cancel")
.build()
biometricPrompt.authenticate(promptInfo)
}
}
A Real Implementation Story: Testing Saves Production
During a certificate rotation, our testing team reported failures but dismissed them as “certificate pinning is too hard to test.” They had disabled pinning in UAT because it complicated their workflow. With two hours before production release, I investigated personally.
The new certificate used a wildcard Common Name (*.example.com) instead of the specific domain (api.example.com). Our pinning logic mapped exact CNs to public keys—the wildcard didn’t match. If deployed, every mobile user would lose connectivity.
🚫 The UAT Pinning Gap
Why They Disabled Pinning
- "Too difficult to test"
- Frequent certificate changes in UAT
- Self-signed certificates
- "We'll catch issues in production monitoring"
The Dangerous Consequence
- UAT validated nothing about pinning
- All pinning issues would appear in production
- False confidence from "successful" testing
- Users would experience failures testing never caught
I called off the release with one hour to spare. We obtained a certificate with the correct CN format, tested thoroughly, and deployed successfully days later.
✅ Lessons Learned
Never Disable Pinning in UAT
- If production has pinning, UAT must too
- "Too hard to test" means production discovery
- UAT must mirror production behavior
- Accept operational burden as necessary
Never Dismiss Test Failures
- Investigate every failure to root cause
- Don't assume "it will work in production"
- Certificate pinning failures are signals
Design for Flexibility
- CN-to-pin mapping allowed key rotation
- Could update keys without app updates
- Balanced security with operational reality
The difficulty of testing with pinning enabled isn’t a bug—it’s your early warning system. Embrace the operational burden as the price of proper security validation.
Implementation Checklist
Before deploying security code to production:
✅ Pre-Deployment Verification
Secure Storage
- Using platform APIs (Keychain/Keystore)
- Correct accessibility settings
- No sensitive data in logs
- Backup exclusions configured
Network Security
- TLS 1.2+ enforced
- Certificate validation enabled
- No cleartext traffic allowed
- Request signing implemented
Code Protection
- ProGuard/R8 enabled for release
- Sensitive strings obfuscated
- Logging removed from release builds
- Mapping files archived
Runtime Protection
- Root/jailbreak detection implemented
- Appropriate response strategy
- Debugger detection in place
- Graceful degradation tested
Testing
- Tested on real devices
- Multiple OS versions verified
- Failure scenarios tested
- Security features enabled in all environments
Conclusion
Security implementation is where theory meets reality. Platform-provided APIs like iOS Keychain and Android Keystore offer hardware-backed encryption superior to any custom implementation. Network security requires proper TLS configuration, certificate validation, and request signing—HTTPS alone is insufficient. Code obfuscation raises the bar for reverse engineering but must be combined with server-side validation. Runtime protection detects compromised environments, allowing appropriate responses.
The gap between security design and secure code is where breaches occur. Misconfigured Keychain accessibility exposes data. Disabled certificate validation in production allows man-in-the-middle attacks. Weak obfuscation provides false confidence. Naive root detection is easily bypassed. Each implementation detail matters.
Testing security implementations is non-negotiable. Test on real devices across multiple OS versions. Test failure scenarios—what happens when Keychain access fails, when certificates are invalid, when devices are rooted? Security that works in ideal conditions but fails in edge cases provides false confidence. And never disable security features in testing environments because they’re “too hard”—that difficulty is your early warning system.
The certificate rotation incident demonstrates why proper testing matters. Disabling pinning in UAT because it complicated testing meant the first real test would be in production. Only maintaining a separate staging environment with pinning enabled caught the issue. The operational burden of testing with security features enabled is far less than the cost of a production outage.
Platform security APIs evolve, vulnerabilities are discovered, and attack techniques advance. Stay informed about security updates, monitor advisories for libraries you use, and regularly review implementations. Yesterday’s best practices may be today’s vulnerabilities. Mobile security is an ongoing process, not a one-time implementation.
Before deploying security code, understand what threat you’re defending against and whether the implementation actually provides protection. Not every app needs root detection or code obfuscation. Match security investments to your risk profile. Focus on fundamentals—secure storage, proper TLS, strong authentication—before adding advanced protections.
The devices may be in users’ pockets, but the security responsibility remains yours. Implement carefully, test thoroughly, and never take shortcuts that compromise security for convenience.