모바일 앱에서 Access Token을 저장하는 위치
모바일 앱에서 Access Token을 저장하는 위치
모든 token을 같은 방식으로 영구 저장할 필요는 없다. 짧게 만료되는 access token은 가능한 한 memory에서만 사용하고, 앱 재시작 뒤 session 복원에 필요한 refresh credential은 iOS Keychain이나 Android Keystore로 보호한 키 기반 저장소에 둔다. Android Keystore는 임의 문자열 저장소가 아니라 추출하기 어려운 암호 키를 보관하고 그 키로 token ciphertext를 보호하는 구조다. 안전한 저장은 rooted device를 완전히 막는 약속이 아니므로 token의 TTL·audience·scope, rotation, server revoke, 로그 삭제가 함께 필요하다.
목차
- #무엇을 저장할지부터 다시 묻기
- #Token별 수명과 권한을 분리하기
- #공격 표면은 Disk에만 있지 않다
- #저장 위치 선택 기준
- #Access Token은 Memory를 우선하기
- #iOS Keychain에 Refresh Credential 저장하기
- #Android Keystore는 Token 문자열 저장소가 아니다
- #Flutter에서는 Session Store 추상화 뒤에 숨기기
- #Secure Storage Plugin도 설정을 확인해야 한다
- #동시 Refresh와 Token 교체를 원자적으로 처리하기
- #로그아웃과 원격 폐기를 함께 설계하기
- #Backup과 기기 이동 정책 정하기
- #생체 인증을 모든 Token에 붙이지 않기
- #Rooting과 탈옥 이후의 한계 인정하기
- #테스트해야 할 저장 상태 행렬
- #운영 로그와 사고 대응 준비하기
- #구현 체크리스트
- #마무리
- #관련 노트
- #참고 자료
무엇을 저장할지부터 다시 묻기
로그인 API가 access token과 refresh token을 반환하면 두 값을 함께 secure storage에 넣는 코드부터 작성하기 쉽다.
const storage = FlutterSecureStorage();
await storage.write(
key: 'access_token',
value: response.accessToken,
);
await storage.write(
key: 'refresh_token',
value: response.refreshToken,
);
일반 preferences보다 나은 출발일 수 있지만, “앱 재시작 뒤 access token까지 그대로 복원해야 하는가?”라는 질문이 빠져 있다.
Access token이 10분 안에 만료되고 refresh credential로 새 token을 받을 수 있다면 access token을 disk에 지속할 이유가 작다. 앱 실행 중 memory에만 두고 process가 종료되면 함께 사라지게 할 수 있다.
반대로 refresh credential은 session 복원을 위해 재시작 뒤에도 필요할 수 있다. 더 긴 수명과 더 큰 권한을 가지므로 platform security storage가 필요하다.
비밀값을 안전하게 저장하는 가장 단순한 방법은 기능이 허용하는 범위에서 지속 저장할 비밀의 수를 줄이는 것이다.
이 글의 token, alias, endpoint는 실제 프로젝트에서 가져온 값이 아닌 가상의 예시다.
Token별 수명과 권한을 분리하기
Access Token과 Refresh Token의 역할 분리에서 다룬 것처럼 두 token의 역할이 다르다.
| 값 | 사용 목적 | 권장 수명 | 기본 저장 |
|---|---|---|---|
| Access token | API resource 접근 | 짧음 | memory 우선 |
| Refresh credential | 새 access token 발급 | 상대적으로 김 | platform secure storage |
| ID token | 인증 정보 전달·검증 | protocol에 따름 | 불필요한 영구 저장 금지 |
| Device session ID | session 관리·폐기 | 정책에 따름 | 비밀 여부에 맞춰 결정 |
Access token scope도 작게 만든다.
{
"aud": "mobile-api",
"scope": "entries:read entries:write",
"exp": 1769793000,
"session_id": "session-demo-8"
}
JWT payload는 서명돼도 기본적으로 암호화된 비밀문이 아니다. 사용자 개인정보를 불필요하게 넣지 않고, 앱에서 decode한 claim을 server authorization의 대체로 사용하지 않는다.
Refresh token rotation을 사용한다면 저장소는 “현재 refresh credential 하나”를 원자적으로 교체해야 한다. 이전 token이 유출되거나 재사용될 때 server가 탐지·폐기할 수 있어야 한다.
공격 표면은 Disk에만 있지 않다
secure storage를 사용하면 at-rest 위험은 줄지만 token이 사용되는 순간에는 memory와 network request에 나타난다.
flowchart LR
A[Secure storage] --> B[App memory]
B --> C[HTTP Authorization header]
C --> D[TLS connection]
B --> E[Debug log risk]
B --> F[Crash dump risk]
B --> G[WebView bridge risk]검토할 공격 표면:
- preferences·database·backup의 평문
- Keychain·Keystore 접근 조건
- memory inspection과 runtime hooking
- HTTP interceptor의 header log
- crash report breadcrumb
- clipboard와 screenshot
- WebView JavaScript bridge
- rooted·jailbroken device
secureStorage.read() 뒤 token을 전역 singleton string으로 무기한 유지하면 disk 보호만 강하고 memory 수명은 길어진다. 반대로 매 API마다 Keychain을 읽으면 성능과 잠금 상태 오류, background 접근 문제가 늘 수 있다. session manager가 필요한 동안만 cache하고 만료·logout 때 교체한다.
저장 위치 선택 기준
| 저장소 | 적합한 데이터 | Token 저장 판단 |
|---|---|---|
| Dart memory | 짧은 access token | 우선 선택 |
SharedPreferences·UserDefaults |
UI preference | 비밀 token 금지 |
| 일반 SQLite/file | 업무 cache | 평문 token 금지 |
| iOS Keychain | 작은 credential | refresh credential |
| Android Keystore | cryptographic key | token 암호화 key |
| Keystore key + encrypted file/preferences | 작은 ciphertext | refresh credential 구현 |
| App Group defaults | widget 표시 데이터 | token 금지 |
| WebView localStorage | Web persistent state | Native refresh token 금지 |
Android의 SharedPreferences에 token을 넣고 파일 자체 permission만 믿지 않는다.
// 피해야 할 예
preferences.edit()
.putString("refresh_token", token)
.apply()
Base64 encoding도 보호가 아니다.
Access Token은 Memory를 우선하기
session manager는 access token과 expiry를 memory에 둔다.
final class AccessSession {
const AccessSession({
required this.token,
required this.expiresAt,
required this.generation,
});
final String token;
final DateTime expiresAt;
final int generation;
bool isUsable(DateTime now) {
return expiresAt.difference(now) >
const Duration(seconds: 30);
}
}
final class InMemoryAccessSessionStore {
AccessSession? _current;
AccessSession? read(DateTime now) {
final value = _current;
return value != null && value.isUsable(now)
? value
: null;
}
void replace(AccessSession value) {
_current = value;
}
void clear() {
_current = null;
}
}
Dart string을 clear()한다고 runtime memory에서 bytes가 즉시 zeroize된다는 보장은 없다. memory-only는 완전한 삭제 기술이 아니라 token이 disk backup과 장기 persistence에 남지 않게 하는 수명 제한이다.
앱 시작 시 refresh credential을 읽고 새 access token을 발급받는다.
Future<AccessSession?> restoreSession() async {
final refreshCredential =
await credentialStore.readRefreshCredential();
if (refreshCredential == null) {
return null;
}
final rotated = await authApi.refresh(refreshCredential);
await credentialStore.replaceRefreshCredential(
expectedGeneration: refreshCredential.generation,
next: rotated.refreshCredential,
);
accessSessions.replace(rotated.accessSession);
return rotated.accessSession;
}
offline startup에서는 마지막 access token을 저장해 사용할지보다 authenticated server data가 필요한 기능과 offline local data를 분리한다. 만료된 token으로 “로그인됨”을 보장하지 않는다.
iOS Keychain에 Refresh Credential 저장하기
iOS Keychain은 작은 secret data와 검색·접근 속성을 함께 저장한다.
struct RefreshCredential: Codable {
let token: String
let generation: Int
}
enum CredentialKeychain {
static let service = "dev.example.auth"
static let account = "refresh-credential.v1"
}
저장 query:
func addCredential(_ credential: RefreshCredential) throws {
let data = try JSONEncoder().encode(credential)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: CredentialKeychain.service,
kSecAttrAccount as String: CredentialKeychain.account,
kSecAttrAccessible as String:
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
kSecValueData as String: data
]
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw KeychainStoreError.status(status)
}
}
AfterFirstUnlockThisDeviceOnly는 background 접근과 새 기기 이동 금지를 가정한 예시다. foreground에서만 사용하는 매우 민감한 credential이면 더 제한적인 accessibility가 적합할 수 있다. 구체적인 차이는 App Group UserDefaults와 Keychain의 역할 차이에서 다뤘다.
기존 item이 있으면 SecItemAdd를 반복해 duplicate 오류를 무시하지 않고 SecItemUpdate로 교체한다. service와 account 문자열은 migration·delete에서도 동일하게 사용한다.
Android Keystore는 Token 문자열 저장소가 아니다
Android Keystore는 cryptographic key material을 추출하기 어렵게 보관하고 key의 사용 목적과 인증 조건을 제한한다. 일반적인 구조는 Keystore의 AES key로 token을 암호화하고 ciphertext·IV를 앱 private storage에 저장하는 것이다.
flowchart LR
A[Refresh credential] --> B[AES-GCM encrypt]
C[Android Keystore key] --> B
B --> D[Ciphertext + IV]
D --> E[App private preferences or file]
C --> F[Non-exportable key material]AES key 생성 예:
private const val KEY_ALIAS = "refresh-credential-key-v1"
fun createKeyIfMissing(): SecretKey {
val keyStore = KeyStore.getInstance("AndroidKeyStore").apply {
load(null)
}
val existing = keyStore.getKey(KEY_ALIAS, null)
if (existing is SecretKey) return existing
val generator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore",
)
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or
KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(
KeyProperties.ENCRYPTION_PADDING_NONE,
)
.build(),
)
return generator.generateKey()
}
암호화할 때 매번 새 IV를 사용하고 ciphertext와 함께 저장한다.
data class EncryptedCredential(
val cipherTextBase64: String,
val ivBase64: String,
val schemaVersion: Int,
)
fun encrypt(
plaintext: ByteArray,
key: SecretKey,
): EncryptedCredential {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key)
val cipherText = cipher.doFinal(plaintext)
return EncryptedCredential(
cipherTextBase64 = Base64.encodeToString(
cipherText,
Base64.NO_WRAP,
),
ivBase64 = Base64.encodeToString(
cipher.iv,
Base64.NO_WRAP,
),
schemaVersion = 1,
)
}
복호화 시 GCM authentication 실패, key 없음, schema 불일치를 서로 구분한다. key가 삭제됐는데 ciphertext만 backup에서 복원된 상황도 정상 복구 경로로 다룬다. 직접 crypto wrapper를 유지할 여력이 없다면 검증된 platform secure storage library를 사용하되 그 library의 실제 Android 구현과 backup 정책을 확인한다.
암호 코드는 API 모양을 보여 주기 위한 축약 예시다. production에서는 key invalidation, authenticated encryption 실패, thread safety, backup, migration을 포함한 검증된 구현을 사용한다.
Flutter에서는 Session Store 추상화 뒤에 숨기기
UI와 API repository가 secure storage key를 직접 읽지 않게 한다.
abstract interface class RefreshCredentialStore {
Future<RefreshCredential?> read();
Future<void> replace({
required int expectedGeneration,
required RefreshCredential next,
});
Future<void> clear();
}
abstract interface class AccessSessionProvider {
Future<AccessSession> getUsableSession();
Future<void> invalidate(AccessSession rejected);
}
HTTP client는 provider에 token을 요청한다.
Future<Response> sendAuthorized(Request request) async {
final session = await sessionProvider.getUsableSession();
return transport.send(
request.withHeader(
'Authorization',
'Bearer ${session.token}',
),
);
}
request object의 toString()과 interceptor log가 header를 출력하지 않는지 확인한다. auth header는 redaction 대상이다.
test에서는 실제 Keychain을 호출하지 않고 in-memory fake를 주입한다. platform secure storage 자체는 실제 기기 integration test로 검증한다.
Secure Storage Plugin도 설정을 확인해야 한다
FlutterSecureStorage() 한 줄이 제품 보안 정책을 자동으로 선택해 주지는 않는다.
확인할 항목:
- iOS Keychain accessibility 기본값
- Keychain access group 공유 여부
- Android에서 key와 ciphertext를 어떻게 저장하는지
- hardware-backed 여부를 보장하는지 또는 기기별인지
- backup에 무엇이 포함되는지
- 생체 인증·user presence option
- 앱 삭제·재설치 뒤 동작
- plugin upgrade migration
- platform exception mapping
final class PlatformRefreshCredentialStore
implements RefreshCredentialStore {
PlatformRefreshCredentialStore(this.storage);
final SecureKeyValueStorage storage;
static const key = 'auth.refresh-credential.v1';
@override
Future<RefreshCredential?> read() async {
final encoded = await storage.read(key);
if (encoded == null) return null;
try {
return RefreshCredential.decode(encoded);
} catch (_) {
throw const CredentialStoreFailure.corrupted();
}
}
}
library 이름보다 실제 platform 설정과 threat model을 문서화한다.
동시 Refresh와 Token 교체를 원자적으로 처리하기
여러 API가 동시에 만료를 감지하면 refresh 요청이 겹칠 수 있다.
Future<AccessSession>? _refreshInFlight;
Future<AccessSession> getUsableSession() {
final cached = accessSessions.read(clock.now());
if (cached != null) return Future.value(cached);
return _refreshInFlight ??=
_refresh().whenComplete(() => _refreshInFlight = null);
}
rotation response에는 refresh generation을 포함한다.
Future<AccessSession> _refresh() async {
final current = await credentialStore.read();
if (current == null) {
throw const SessionFailure.loginRequired();
}
final response = await authApi.rotate(current.token);
await credentialStore.replace(
expectedGeneration: current.generation,
next: RefreshCredential(
token: response.refreshToken,
generation: current.generation + 1,
),
);
final access = AccessSession(
token: response.accessToken,
expiresAt: response.expiresAt,
generation: current.generation + 1,
);
accessSessions.replace(access);
return access;
}
logout generation과 refresh generation을 비교해 logout 전에 시작한 응답이 늦게 도착해 credential을 다시 저장하지 못하게 한다.
Keychain write 성공 전에 old token이 server에서 폐기되는 rotation은 response 유실 시 session을 잃을 수 있다. server rotation 정책과 grace/reuse detection을 함께 설계한다.
로그아웃과 원격 폐기를 함께 설계하기
logout 순서에는 경쟁과 부분 실패가 있다.
sequenceDiagram
participant UI
participant Session
participant Server
participant Secure as Secure storage
participant Cache
UI->>Session: logout
Session->>Session: 새 인증 요청 차단
Session->>Server: session revoke
Session->>Secure: refresh credential 삭제
Session->>Cache: access token·private cache 삭제
Session-->>UI: signed outserver revoke가 offline으로 실패해도 local logout은 진행하고 revoke retry를 위한 비밀을 어떻게 다룰지 정한다. token 원문을 일반 offline queue에 저장해서는 안 된다. server가 session ID 기준 revoke를 지원하거나, short expiry와 refresh rotation으로 위험을 제한할 수 있다.
삭제할 대상:
- memory access session
- refresh credential
- WebView auth cookie
- account-private database·image cache
- push token의 account binding
- pending authenticated request
Keychain delete 실패를 무시하고 로그인 화면만 띄우면 다음 시작 때 session이 복원될 수 있다. 삭제 결과를 확인하고 local auth generation으로 복원을 차단한다.
Backup과 기기 이동 정책 정하기
새 기기 backup 복원 뒤 자동 로그인할지 제품 정책이 필요하다.
iOS accessibility 이름의 ThisDeviceOnly 변형은 item이 다른 기기 복원으로 이동하지 않게 한다. Android에서는 Keystore key와 ciphertext backup 정책이 어긋나면 복원된 ciphertext를 복호화할 수 없다.
선택지:
| 정책 | 장점 | 비용 |
|---|---|---|
| 새 기기에서 재로그인 | 기기 이전 위험 감소 | 사용자 friction |
| credential migration 허용 | 편리함 | backup·계정 보호 요구 증가 |
| device-bound session | server revoke 단순화 | 기기 등록 관리 |
복호화 불가능한 ciphertext를 무한 재시도하지 않는다.
try {
return await credentialStore.read();
} on KeyMissingAfterRestore {
await credentialStore.clearCiphertext();
throw const SessionFailure.loginRequired();
}
backup 설정은 앱 업데이트와 기기 이전 실제 시나리오로 시험한다.
생체 인증을 모든 Token에 붙이지 않기
Keychain·Keystore key 사용에 Face ID, Touch ID, biometric prompt를 요구할 수 있다. 민감 작업의 재인증에는 유용하지만 모든 API 요청마다 prompt가 필요하면 앱을 사용할 수 없다.
일반 session 복원과 고위험 작업 승인을 구분한다.
일반 API access
refresh credential → background 가능 정책
계좌 변경·비밀 보기
user presence로 별도 key 또는 step-up token 해제
background push 처리나 widget이 필요한 credential에 user presence를 요구하면 UI를 띄울 수 없는 시점에 실패한다. 가능한 가장 제한적인 접근 조건을 고르되 기능 수명주기와 맞춰야 한다.
Rooting과 탈옥 이후의 한계 인정하기
플랫폼 secure storage는 key extraction과 at-rest 공격을 어렵게 만들지만 공격자가 앱 process를 완전히 제어하는 상황에서 사용 중인 plaintext token까지 절대 보호하지는 못한다.
방어를 겹친다.
- 짧은 access token TTL
- refresh rotation과 reuse detection
- 좁은 audience·scope
- server-side authorization
- device session 목록과 원격 logout
- 이상 징후 탐지와 revoke
- 고위험 작업 step-up authentication
- token을 로그·WebView·clipboard로 복제하지 않음
root detection 하나로 모든 보안을 맡기거나 탐지 결과만으로 사용자 데이터를 파괴하지 않는다. 우회 가능성과 오탐을 포함한 risk signal로 사용한다.
테스트해야 할 저장 상태 행렬
| 상황 | 기대 결과 |
|---|---|
| 첫 로그인 | refresh credential만 지속 저장 |
| 앱 재시작 | refresh로 새 access token 발급 |
| access token 만료 | single-flight refresh |
| refresh rotation 중 앱 종료 | 일관된 복구 또는 재로그인 |
| 동시 401 여러 개 | refresh 한 번 |
| logout 중 refresh 응답 | generation 불일치로 폐기 |
| Keychain 잠금 상태 | 일시 불가와 item 없음 구분 |
| Android key invalidation | ciphertext 삭제 후 재로그인 |
| 새 기기 backup 복원 | 정책대로 migration 또는 재로그인 |
| plugin upgrade | 기존 credential migration |
| 계정 전환 | 이전 account token·cache 제거 |
| rooted test 환경 | server-side 제한 유지 |
보안 test에서는 preferences·database·backup·log에서 token pattern이 발견되지 않는지 확인한다. HTTP logger가 release build에서 auth header를 redact하는지도 검증한다.
운영 로그와 사고 대응 준비하기
token 없이도 session 흐름을 관측할 수 있다.
session_restore result=success generation=8
access_refresh result=failed category=network
credential_read result=interaction_not_allowed
logout_cleanup secure_store=success web_cookie=success
refresh_reuse result=detected session_revoked=true
기록하지 않는 값:
- token 전체 또는 앞뒤 일부
- Authorization header
- Keychain query의 secret data
- encrypted blob 원문
- session cookie
token fingerprint도 다른 로그와 결합해 추적 식별자가 될 수 있으므로 꼭 필요한지 검토한다. server의 별도 session ID를 사용한다.
사고 대응에는 사용자별 device session 조회, 특정 session revoke, refresh family 전체 폐기, 강제 재로그인 수단이 필요하다. local encryption만으로 server에서 유출 token을 무효화할 수는 없다.
구현 체크리스트
마무리
“Access Token을 어디에 저장할까?”의 첫 답은 저장소 이름이 아니다. 그 access token을 process 종료 뒤에도 보관할 필요가 있는지부터 묻는 것이다. 짧게 만료되는 access token은 memory에서만 사용하고, session 복원에 필요한 refresh credential만 플랫폼 보안 저장소에 지속하는 구성이 기본이다.
iOS Keychain은 작은 secret item과 accessibility를 관리한다. Android Keystore는 token 문자열 자체가 아니라 추출하기 어려운 cryptographic key를 보관하며, 앱은 그 key로 refresh credential을 authenticated encryption해 private storage에 둔다.
이 저장소들도 rooted·jailbroken process에서 사용 중인 token을 완전히 숨기는 마법은 아니다. 짧은 TTL, 좁은 scope, refresh rotation, server revoke, step-up authentication이 함께 있어야 한다.
안전한 token 저장은 암호화 API를 한 번 호출하는 일이 아니다. token을 덜 저장하고, 더 짧게 사용하고, 정확히 교체하며, logout과 사고 시 확실히 폐기하는 수명주기 설계다.
관련 노트
- Access Token과 Refresh Token의 역할 분리
- Refresh Token Rotation으로 재사용 공격 줄이기
- OAuth state와 PKCE가 막아주는 공격
- App Group UserDefaults와 Keychain의 역할 차이
- WebView 로그인 세션을 안전하게 전달하기
- 환경 변수를 설정과 비밀값으로 나누기