이 글은 누구를 위한 것인가
- 앱 잠금 해제 또는 결제 확인에 생체인증을 적용하려는 팀
- Keystore와 Keychain으로 생체정보 키를 안전하게 관리하려는 개발자
- 생체인증 실패 시 폴백 처리를 설계하려는 팀
들어가며
"매번 비밀번호 입력이 불편하다" — 생체인증은 편의성과 보안을 동시에 제공한다. 생체정보 자체는 기기에 저장되고 앱에 전달되지 않는다. 앱이 받는 것은 "인증 성공/실패" 결과뿐이다.
이 글은 bluefoxdev.kr의 모바일 생체인증 Face ID BiometricPrompt 가이드 를 참고하여 작성했습니다.
1. 생체인증 보안 아키텍처
[iOS 생체인증]
LocalAuthentication: Face ID / Touch ID
Keychain: 생체인증으로 보호된 키 저장
보안 수준:
- .biometryCurrentSet: 현재 등록된 생체정보만
- .biometryAny: 어떤 생체정보든 (추가/변경 허용)
- .deviceOwnerAuthentication: 생체 + PIN 폴백
[Android 생체인증]
BiometricPrompt: 통합 생체인증 UI
Keystore: 하드웨어 보안 모듈 키 관리
인증 레벨:
- BIOMETRIC_STRONG: 강력 (하드웨어 보안 모듈)
- BIOMETRIC_WEAK: 약한 (얼굴 인식 등)
- DEVICE_CREDENTIAL: 기기 PIN/패턴
[Keychain + 생체인증 결합]
1. 생체인증으로 보호된 Keychain 항목 생성
2. 항목 접근 시 자동으로 생체인증 요청
3. 인증 성공 → 키 반환 → 데이터 복호화
[폴백 전략]
1회 실패: 재시도 허용
3회 실패: PIN 폴백 제시
생체 불가 기기: 항상 PIN
2. 생체인증 구현
import LocalAuthentication
import Security
import SwiftUI
// iOS 생체인증
class BiometricAuthManager {
private let context = LAContext()
var biometricType: String {
context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
switch context.biometryType {
case .faceID: return "Face ID"
case .touchID: return "Touch ID"
default: return "생체인증"
}
}
func authenticate(reason: String) async -> Result<Bool, Error> {
return await withCheckedContinuation { continuation in
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, error in
if success {
continuation.resume(returning: .success(true))
} else if let error = error {
continuation.resume(returning: .failure(error))
}
}
}
}
// Keychain + 생체인증으로 토큰 저장
func saveTokenWithBiometric(_ token: String, for key: String) throws {
let tokenData = token.data(using: .utf8)!
// 생체인증 접근 제어 생성
var error: Unmanaged<CFError>?
guard let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.biometryCurrentSet, // 현재 등록된 생체만 허용
&error
) else { throw error!.takeRetainedValue() as Error }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: tokenData,
kSecAttrAccessControl as String: access,
]
SecItemDelete(query as CFDictionary)
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError.saveFailure }
}
// 생체인증으로 토큰 읽기
func getTokenWithBiometric(for key: String, reason: String) throws -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecUseOperationPrompt as String: reason,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
}
enum KeychainError: Error { case saveFailure }
// SwiftUI 생체인증 뷰
struct BiometricLockView: View {
@State private var isAuthenticated = false
@State private var error: String?
private let authManager = BiometricAuthManager()
var body: some View {
if isAuthenticated {
Text("잠금 해제됨")
} else {
VStack(spacing: 20) {
Image(systemName: "faceid").font(.system(size: 60)).foregroundColor(.blue)
Text("\(authManager.biometricType)로 잠금 해제")
if let error = error { Text(error).foregroundColor(.red).font(.caption) }
Button("인증하기") { authenticate() }
.buttonStyle(.borderedProminent)
}
}
}
private func authenticate() {
Task {
let result = await authManager.authenticate(reason: "앱 잠금을 해제합니다")
switch result {
case .success: await MainActor.run { isAuthenticated = true }
case .failure(let err): await MainActor.run { error = err.localizedDescription }
}
}
}
}
// Android BiometricPrompt
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
class BiometricManager(private val activity: FragmentActivity) {
private val executor = ContextCompat.getMainExecutor(activity)
fun authenticate(
title: String,
subtitle: String,
onSuccess: (BiometricPrompt.AuthenticationResult) -> Unit,
onError: (String) -> Unit
) {
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setNegativeButtonText("취소")
.setAllowedAuthenticators(
androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
)
.build()
val biometricPrompt = BiometricPrompt(
activity,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onSuccess(result)
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
onError(errString.toString())
}
override fun onAuthenticationFailed() {
// 개별 실패 (3회 후 잠금)
}
}
)
// Keystore 연동: 암호화 객체와 함께 인증
val cipher = getCipher()
val cryptoObject = BiometricPrompt.CryptoObject(cipher)
biometricPrompt.authenticate(promptInfo, cryptoObject)
}
private fun getCipher(): Cipher {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val keyGenerator = KeyGenerator.getInstance("AES", "AndroidKeyStore")
keyGenerator.init(
android.security.keystore.KeyGenParameterSpec.Builder("biometric_key",
android.security.keystore.KeyProperties.PURPOSE_ENCRYPT or
android.security.keystore.KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(android.security.keystore.KeyProperties.BLOCK_MODE_CBC)
.setEncryptionPaddings(android.security.keystore.KeyProperties.ENCRYPTION_PADDING_PKCS7)
.setUserAuthenticationRequired(true)
.build()
)
keyGenerator.generateKey()
val secretKey = keyStore.getKey("biometric_key", null) as javax.crypto.SecretKey
return Cipher.getInstance("AES/CBC/PKCS7Padding").apply { init(Cipher.ENCRYPT_MODE, secretKey) }
}
}
마무리
생체인증의 핵심은 생체정보가 앱에 전달되지 않는다는 것이다. iOS는 Keychain + kSecAttrAccessControl으로, Android는 Keystore + BiometricPrompt CryptoObject로 생체인증과 키를 연결한다. 인증 성공 시에만 키에 접근할 수 있어 루팅/탈옥된 기기에서도 키를 직접 추출할 수 없다.