인증 토큰을 WebView localStorage나 일반 SharedPreferences가 아닌, 플랫폼 기반 암호화 저장소에 저장하세요. iOS에서는 Keychain을 사용하세요. Android에서는 Android Keystore를 사용하세요. (기기에 따라 software, TEE 또는 StrongBox)로 키를 보호하고, Jetpack DataStore + Tink로 암호문을 저장하세요.. EncryptedSharedPreferences 사용 중단 예정입니다 에서 androidx.security.crypto; 지금 마이그레이션을 계획하세요. Capacitor 앱의 경우, JavaScript에서 암호화 로직을 중복 구현하는 대신 전송 보안(HTTPS, pinning)과 native-backed storage 플러그인을 결합하여 사용하세요.
스토리지 패턴 선정 기준
이곳의 모든 권장 사항은 네 가지 필터를 거칩니다. 첫째, 하드웨어 지원: 비밀 데이터는 Keychain/Keystore에 저장되거나 이를 지원하는 키로 암호화되어야 합니다. 둘째, 공식 지원되는 마이그레이션 경로: Google이나 Apple이 해당 방식을 중단했다면, 새로운 code에 권장하지 않습니다. 셋째, Capacitor 호환성: 패턴이 JavaScript에 원시 키를 노출하지 않고 플러그인 경계에서 작동해야 합니다. 넷째, 취소 가능성: 유출된 자격 증명은 서버 측에서 무효화할 수 있어야 합니다.
마지막 필터 때문에 이 글 전반에 걸쳐 refresh token rotation이 등장합니다. Chainalysis가 정리한 2024년 사고 데이터를 검토한 결과, 개인 키 및 자격 증명과 관련된 침해 사례가 탈취된 암호화폐의 대부분을 차지했습니다(일본 DMM Bitcoin에서 3억 500만 달러 이상, 인도의 WazirX에서 2억 3,500만 달러 탈취 포함). 공통적인 실패 원인은 단일하고 지속적으로 유효한 자격 증명을 무조건 신뢰한 것이었습니다(Chainalysis 2024 범죄 보고서). 모바일 인증 토큰도 같은 방식으로 실패합니다. 평문 스토리지에 저장된 장기 bearer token은 해커에게 문을 열어주는 것과 같습니다. 저희는 rotation되지 않는 자격 증명을 무기한 유지하는 아키텍처는 더 이상 권장하지 않습니다.
모든 플랫폼에 적용되는 원칙
아래 표는 스냅샷으로 요약되어 있습니다. 핵심 로직은 access/refresh token 분리 방식입니다. 액세스 토큰 은(는) 수명이 짧으며(며칠이 아닌 몇 분 단위), API에서 present라고 부르는 것입니다. 영향 범위가 작고 금방 만료되므로, 앱을 실행할 때마다 메모리에서만 새로 생성해야 합니다. refresh token을 은(는) 새로운 access token을 발행할 수 있게 해주는 영구적인 비밀 키이므로, 하드웨어 기반의 암호화된 영구 저장소에 보관해야 합니다. 수명이 다르기 때문에 보관 방식도 달라야 하며, 두 토큰을 동일한 방식으로 저장하는 것이 보안 유출의 주요 원인이 됩니다.
런타임에서의 전체 시퀀스는 다음과 같습니다: 앱이 access token 없이 실행되면, 저장된 refresh token으로 인증 서버에 요청을 보냅니다. 서버로부터 새로운 access token을 메모리에 받고, 서버가 사용 시마다 토큰을 교체(rotate)하므로 기존 refresh token은 폐기합니다. 만약 서버가 이미 사용된 refresh token이 다시 제출되는 것을 감지하면, 이를 탈취 증거로 간주하고 해당 토큰 패밀리 전체를 무효화합니다. 로그아웃 시 클라이언트는 로컬 스토리지를 삭제하고 서버는 권한을 취소해야 합니다. 서버의 권한 취소 없이 로컬 데이터만 삭제하는 것은 보안이 아니라 요행을 바라는 것입니다.
| 방식 | 왜 중요한가 |
|---|---|
| 수명이 짧은 access token | 토큰 유출 시 피해 최소화 |
| Refresh token rotation | 정상 사용 후 탈취된 refresh token 무효화 |
| HTTPS 필수 사용 | 일반적인 네트워크 가로채기 방지 |
| Certificate pinning (필요 시) | 부적절하게 발급된 인증서나 커스텀 루트의 위험 감소 (OWASP certificate pinning 가이드 참조) |
| 로그, URL, 분석 도구에 토큰 포함 금지 | Observability 도구에 노출된 토큰은 보안 침해로 이어짐 |
| 로그아웃 시 토큰 폐기 | 클라이언트의 데이터 삭제를 기다리는 것보다 서버 측 무효화가 확실함 |
@capgo/capacitor-ssl-pinning 더 엄격한 전송 제어가 필요한 경우 iOS 및 Android에서 Capacitor HTTP용 TLS를 pin할 수 있습니다.
Android: Keystore 및 DataStore (EncryptedSharedPreferences 권장 안 함)
Google에서 다음을 지원 중단함 androidx.security.crypto 다음과 같은 헬퍼를 포함하여, EncryptedSharedPreferences 및 MasterKey. 패키지 요약에는 개발자가 다음을 사용하도록 안내합니다: KeyGenerator AndroidKeyStore 프로바이더와 함께 새 키 자료를 생성합니다.
권장 패턴
Tink 기반의 엔드 투 엔드 파이프라인을 사용하세요:
- 다음으로 Tink keyset을 빌드합니다:
AndroidKeysetManager, 마스터 키를 Android Keystore에 저장하며,android-keystore://URI를 사용합니다 (다음을 참조하세요:datastore-tink릴리스 노트). - 다음 항목을 가져오세요:
Aead해당 키셋의 프리미티브입니다. - 추가
androidx.datastore:datastore-tink(다음 버전부터 사용 가능) DataStore 1.3.0-rc01; stable 1.2.x 버전에는 이 아티팩트가 포함되어 있지 않으므로, 사용 중인Serializer포함AeadSerializer, 및 이를 전달하여DataStore. 여러 저장소가 하나를 공유할 때Aead키를 사용한다면, 각 스토어에 고유한associatedData, 예를 들어 파일명에서 유도한 안정적인 값을 사용하세요.
Google은 이를 지원 중단된 기능을 대체하는 공식 경로로 문서화했습니다. androidx.security.crypto helpers. 단독 JCA를 혼용하지 마세요. SecretKey 포함 AeadSerializer; serializer는 Tink Aead 인스턴스를 기대합니다.
버전 관리에 관한 참고 사항: 해당 datastore-tink 아티팩트는 1.3.0-rc01부터만 존재하므로, 안정 1.2.x 릴리스에 고정된 팀은 문서에 나온 대체 아티팩트가 의존성 트리에 없는 것을 발견하게 됩니다. 이 차이는 이제 여러 릴리스 전부터 이어져 왔고, 저희 편집진이 보기에는 이것이 마이그레이션 계획이 계속 지연되는 주된 이유입니다. 우연히 그렇게 되게 두지 말고, 릴리스 후보(RC)를 사용할지 기다릴지 의도적으로 결정하세요.
알아두면 좋은 Keystore 옵션
setIsStrongBoxBacked(true)할 때FEATURE_STRONGBOX_KEYSTORE더 강력한 키 격리를 위해 사용할 수 있습니다.- 사용자 인증 바인딩 (
setUserAuthenticationRequired) 생체 인식이나 기기 자격 증명을 거친 후에만 토큰에 접근해야 할 때 사용합니다. - Auto Backup 방지 복구 동작을 완벽히 이해하지 못했다면 키셋이나 암호문을 저장하는 파일에 주의해야 합니다. Google의 EncryptedSharedPreferences 문서는 키 없이 암호문만 백업되어 복구되는 위험성을 경고한 바 있습니다.
Capacitor의 Native Kotlin 또는 Java 모듈은 플러그인 내부에서 이 패턴을 구현해야 합니다. JavaScript는 원본 키가 아닌 불투명한 핸들이나 세션 상태만 전달받아야 합니다.
iOS: Keychain 및 Secure Enclave
이 Keychain iOS에서 토큰을 저장하기에 가장 적합한 장소입니다. 항목은 암호화되어 시스템에 의해 보호됩니다. 범위를 좁게 설정한 accessibility 상수를 사용하고, 접근 제어 Face ID, Touch ID 또는 암호 입력이 필요한 경우를 고려하세요.
accessibility 설정은 단순히 복사해서 쓰는 기본값이 아니라 신중히 결정해야 할 문제입니다. 다음 중 하나를 선택하세요. kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly 푸시 트리거 리프레시나 background fetch처럼 백그라운드 작업에서 토큰을 사용해야 하지만, 백업을 통해 새 기기로 이전되어서는 안 되는 경우입니다. kSecAttrAccessibleWhenUnlockedThisDeviceOnly 제약 사항이 있더라도 더 엄격한 보안이 필요한 포그라운드 전용 토큰 사용 시 선택합니다. 두 경우 모두, kSecUseDataProtectionKeychain 최신 타겟에서 이를 활성화하여 레거시 파일 기반 방식이 아닌 데이터 보호 기능이 적용된 Keychain을 사용하세요.
CryptoKit 는 직접 페이로드를 암호화할 때 유용하지만, 표준 OAuth 방식의 bearer token의 경우 적절한 접근 권한(accessibility)을 설정하여 Keychain에 토큰 문자열을 직접 저장하는 것만으로도 충분합니다. Secure Enclave 기반 키(CryptoKit에서 생성된 P-256 키 등)는 SecureEnclave 키 자체가 보안 하드웨어를 절대 벗어나면 안 되는 경우(서명 작업이나 디바이스 인증 등)에만 진가를 발휘합니다. 이 키들은 토큰 문자열을 대신 보관해 주는 것이 아니라, 사용자의 envelope key를 보호하는 역할을 합니다. 만약 envelope를 로테이션하거나 해당 라이프사이클을 직접 관리하지 않는다면, 일반적인 Keychain 저장이 더 간단하면서도 안전합니다. 앱 레이어의 추가 암호화는 키 로테이션과 envelope 관리를 철저히 병행할 때만 의미가 있습니다.
Capacitor의 @capacitor/preferences 는 UserDefaults에 기록하며, 이는 하지 마세요 보안 저장소가 아닙니다. 토큰을 여기에 저장하지 마세요.
Capacitor: Capgo 조직의 보안 저장소 옵션
Capgo는 하지 마세요 “secure storage”라는 패키지를 출시하지만, 일반적인 Capacitor 요구사항은 다음 두 가지 플러그인으로 해결할 수 있습니다:
| 플러그인 | 추천 대상 | 네이티브 백엔드 지원 |
|---|---|---|
@capgo/capacitor-native-biometric setData / getSecureData |
소규모 보안 데이터(세션 토큰, API 키) | iOS Keychain 및 Android Keystore 기반 SharedPreferences 암호화 |
| @capgo/capacitor-data-storage-sqlite 암호화 옵션 포함 | 대규모 또는 구조화된 로컬 데이터 | SQLCipher 옵션을 지원하는 SQLite |
이 Key-value 스토리지 가이드 Preferences, SQLite 스토리지 및 NativeBiometric.setData 보안 데이터 저장 방법 비교
필요 @capgo/capacitor-native-biometric 8.6.0 이상 버전 (플러그인 메이저 버전을 Capacitor 메이저 버전과 맞추세요). 생체 인증 기반 읽기 예시:
import {
AccessControl,
NativeBiometric,
} from '@capgo/capacitor-native-biometric';
const TOKEN_KEY = 'auth.refresh_token';
export async function saveRefreshToken(token: string) {
await NativeBiometric.setData({
key: TOKEN_KEY,
value: token,
accessControl: AccessControl.BIOMETRY_CURRENT_SET,
});
}
export async function loadRefreshToken(): Promise<string | null> {
try {
const { value } = await NativeBiometric.getSecureData({
key: TOKEN_KEY,
reason: 'Unlock your session',
title: 'Authenticate',
});
return value ?? null;
} catch {
return null;
}
}
getData 저장된 값만 반환합니다 없이 accessControl. 생체 인증 보호를 위해 항상 다음을 함께 사용하세요 BIOMETRY_CURRENT_SET 또는 BIOMETRY_ANY 포함 getSecureData.
웹 빌드의 경우, 프로덕션 토큰을 절대 다음 항목에 저장하지 마세요 localStorage. 대신 백엔드의 웹 세션 모델을 사용하세요.
서버와 클라이언트의 토큰 생명주기
네, 모바일 앱은 refresh token을 저장해야 합니다. 그렇지 않으면 사용자가 앱을 실행할 때마다 다시 인증해야 하는데, 이는 실제 보안보다는 보안을 하는 척하는 것에 불과합니다. 핵심은 어디에 및 어떻게저장하느냐입니다: refresh token만 Keychain 또는 Keystore 기반 저장소에 저장하고, rotation과 서버 측 revocation을 반드시 함께 두어야 합니다.
Rotation은 저장을 안전하게 만드는 핵심입니다. refresh token을 일회용 자격 증명으로 발급하세요. 즉, 토큰을 사용할 때마다 새로운 refresh token을 반환하고 기존 토큰은 무효화합니다. 서버는 짧은 재사용 감지(reuse-detection) 윈도우를 유지해야 합니다. 이미 사용된 토큰이 다시 제출되면 해당 토큰 패밀리가 탈취된 것으로 간주하여 전체 체인을 무효화하고, 해당 토큰을 공유하던 모든 기기에서 다시 로그인하도록 강제합니다. 다만, 동시 요청이나 오프라인 재연결을 고려해 약간의 유예 기간(몇 초에서 1분 정도)을 두는 것이 좋습니다. 정당한 클라이언트가 새 토큰을 인지하기 전 마지막으로 이전 토큰을 제출할 수 있기 때문입니다. 유예 기간이 전혀 없으면 불안정한 연결에서 사용자가 접속하지 못하게 될 수 있고, 너무 길면 공격 표면이 넓어집니다.
각 토큰의 저장 위치는 수명에 따라 결정됩니다:
- 문제 access token은 TTL을 짧게(며칠이 아닌 분 단위) 설정하고, 메모리에만 저장하여 세션 동안 유지하세요.
- 다음은 refresh token을 Keychain 또는 Keystore 기반 암호화 저장소에 저장하고, 위와 같이 로테이션하세요.
- 비밀번호 변경, 로그아웃 또는 기기 분실 시에는, 서버 측에서 refresh token을 무효화하세요. 클라이언트에서 로컬 데이터를 삭제하는 것은 무효화가 아닙니다.
- 가벼운 토큰 바인딩 전략(디바이스 키 증명 또는 앱 인스턴스 ID)을 고려하세요. 위협 모델에 따라 필요할 수 있습니다.
Capacitor 앱의 토큰 처리 보안 패치를 배포할 때, Capgo Live Update 사용하기 스토어 정책을 준수한다면 웹 레이어 변경 사항을 빠르게 적용할 수 있습니다. 단, Native storage 변경은 스토어 빌드가 필요합니다.
스토리지 옵션 비교
| 메서드 | 보안 | Capacitor 적합성 |
|---|---|---|
| In-memory JS 변수 | 실행 중에는 안전하지만, 앱 종료 시 소멸 | 세션 중 access token에만 적합 |
localStorage / 환경설정 |
낮음 | 설정만, secret은 절대 금지 |
| iOS Keychain / Android Keystore | 높음 | 네이티브 code 또는 Capgo 플러그인 사용 |
| DataStore + Tink (Android 네이티브) | 높음 | 커스텀 Capacitor 플러그인 또는 네이티브 인증 SDK |
| JS 기반 커스텀 암호화 | 변수 | 주의: JS 키는 추출될 수 있습니다 |
마지막 행에서 주목할 점은, JavaScript crypto의 실패 원인은 취약한 알고리즘이 아니라 위치(placement)에 있다는 것입니다. JS 런타임이 읽을 수 있는 모든 키는 런타임 내의 다른 요소에 의해 유출될 수 있습니다. 따라서 어떤 라이브러리를 선택하더라도 해당 레이어에서는 구조적으로 '높은 보안'을 달성할 수 없습니다.
EncryptedSharedPreferences 마이그레이션 체크리스트
- 비밀 키와 공개 설정(public config) 키를 구분하여 목록화하세요.
- DataStore + 구현
AeadSerializerKeystore 기반 Tink keyset 사용 - 다음 방식으로 마이그레이션:
SharedPreferencesMigration기존의 legacy EncryptedSharedPreferences 인스턴스를 여는 producer를 사용하세요 (plainSharedPreferences아님). 읽기에 성공하면 legacy 파일을 삭제합니다. - Native Build 변경 사항을 스토어 릴리스에 포함하세요. 실제 기기에서 백업 및 복구를 테스트해야 합니다.
- 제거
androidx.security.crypto참조하는 곳이 없는 종속성.
자주 묻는 질문
Keychain에 저장하기 전에 토큰을 암호화해야 하나요?
Keychain 접근 권한이 위협 모델과 일치한다면, 일반적인 bearer 토큰에는 별도의 암호화 계층이 필요하지 않은 경우가 많습니다. 규정이나 키 로테이션 정책이 필요한 경우에는 envelope encryption을 추가하세요.
Capacitor 플러그인의 SQLCipher가 Keystore를 대체할 수 있나요?
SQLCipher는 데이터베이스 파일을 보호하지만, 데이터베이스를 복호화하려면 Keystore나 Keychain에 강력한 키가 여전히 필요합니다. 암호화 패스프레이즈 처리를 보안 저장소 설계의 일부로 다루세요.
Capacitor Preferences에 난독화하여 토큰을 저장해도 되나요?
난독화는 암호화가 아닙니다. Preferences는 하드웨어 기반 보호 기능 없이 UserDefaults 및 SharedPreferences에 매핑됩니다.
모바일 앱에서 refresh token을 저장해야 하나요?
네. 하지만 Keychain이나 Keystore 기반 암호화 저장소에만 저장하고, 서버 측 로테이션 및 취소와 함께 사용해야 합니다. 저장하지 않으면 앱을 실행할 때마다 재인증해야 하며, 사용자는 더 약한 로그인 요구사항을 요구해 이를 피합니다. 절대 허용할 수 없는 것은 Preferences, localStorage 또는 평문 파일에 refresh token을 저장하는 것입니다.
최신 앱에서 access token을 저장하는 가장 안전한 방법은 무엇인가요?
토큰을 아예 저장하지 마세요. 세션 동안에는 수명이 짧은 access token을 메모리에만 유지하고, 앱을 실행할 때마다 refresh token을 사용해 다시 생성하세요. refresh token만 iOS Keychain이나 Android Keystore 기반의 암호화된 저장소에 저장하고, 토큰 rotation 및 revocation 기능을 활용해 기기가 탈취되더라도 피해를 최소화하세요.
Secure Token Storage: Best Practices for Mobile Developers 시리즈 계속 보기
사용 중이라면 안전한 토큰 저장: 모바일 개발자를 위한 베스트 프랙티스 보안 및 컴플라이언스 계획을 세우려면, 다음 항목과 연결하세요: 암호화 암호화 구현 상세 정보: 컴플라이언스 컴플라이언스 구현 상세 정보: Capgo 보안 스캐너 Capgo Security Scanner 제품 워크플로우: Capgo 보안 Capgo Security 제품 워크플로우 및 Capgo 신뢰 센터 Capgo Trust Center 제품 워크플로우: