Implementing PIN Code for Mobile App Login
PIN code — local second factor: user enters full credentials once, then unlocks app with PIN. Not server authentication — local storage with credentials unlock.
Key concept: PIN cannot be stored. In any form. Even hashed PIN without salt — unsafe (4–6 digits, brute force all variants takes seconds).
Correct cryptographic scheme
PIN used for key derivation, which encrypts actual secret (refresh token or symmetric data encryption key). Scheme:
- Generate random
salt(16–32 bytes,SecRandomCopyBytes/SecureRandom). - Derive key from PIN + salt via PBKDF2 (minimum 100,000 iterations, SHA-256) or Argon2id.
- Encrypt refresh token with derived key (AES-256-GCM).
- Save ciphertext + salt + IV to Keychain/EncryptedSharedPreferences.
- Never save PIN.
// iOS — key derivation from PIN
func deriveKey(from pin: String, salt: Data) throws -> SymmetricKey {
let pinData = Data(pin.utf8)
var derivedKey = Data(count: 32)
let result = derivedKey.withUnsafeMutableBytes { derivedKeyPtr in
pinData.withUnsafeBytes { pinPtr in
salt.withUnsafeBytes { saltPtr in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
pinPtr.baseAddress, pinData.count,
saltPtr.baseAddress, salt.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
100_000,
derivedKeyPtr.baseAddress, 32
)
}
}
}
guard result == kCCSuccess else { throw CryptoError.keyDerivationFailed }
return SymmetricKey(data: derivedKey)
}
PIN verification on entry: try decrypt AES-GCM with derived key. If decryption succeeded (tag matched) — PIN correct. If not — wrong. No isPinCorrect flags in storage.
UI: custom keyboard mandatory
System keyboard for PIN — bad idea for several reasons:
- iOS and Android show predictive input above keyboard — PIN can get into autocorrect dictionary.
- System keyboard has fixed layout — no digit randomization.
- Third parties theoretically intercept input via
InputMethodService(Android).
Make custom digit keyboard. In SwiftUI — LazyVGrid with buttons, no UITextField. In Jetpack Compose — same via LazyVerticalGrid. Display entered digits — filled/empty circles, no text.
Digit layout randomization (shuffle) — optional for high-security apps. Complicates shoulder surfing attacks.
Error counter and lockout
After N failed attempts (usually 3–5) — lockout. Options:
- Soft: delay between attempts, growing exponentially (30 sec → 5 min → 30 min).
- Hard: PIN login blocked, requires full credential login.
- Very hard (enterprise): app data wipe after 10 failed attempts.
Error counter stored in Keychain/EncryptedSharedPreferences — not UserDefaults, otherwise user can reset counter via app delete/restore from backup.
PIN change
Old PIN → decrypt secret → new PIN → derive new key → encrypt again → save with new salt and IV. Atomically: first write new data to temp key, check decryption works, only then delete old.
Biometrics + PIN
Biometrics — convenience, PIN — mandatory fallback. On Face ID/Touch ID lockout system requires device passcode, not app PIN. Different things. App PIN must work independently from system biometric state.
Architecturally: LocalAuthService with unlock() method, tries biometrics and on denial/unavailability switches to PIN screen. Decision what to show first — app configuration or user preference.
Timeframe
Implementing PIN with correct cryptographic scheme, custom keyboard, error counter, and biometric fallback — 5–8 business days.







