블로그 목록
apps

expo-secure-store로 토큰 안전하게 저장하기 — AsyncStorage에 JWT 넣지 말자

로그인 붙이면서 access token, refresh token을 어디에 저장할지 고민했다. 처음엔 별생각 없이 AsyncStorage를 썼는데, 이게 그냥 평문으로 앱 샌드박스에 저장된다는 걸 알고 나서 찜찜해졌다.

탈옥/루팅된 기기거나, 백업 파일을 뜯어보면 토큰이 그대로 노출된다. 세션 토큰은 그 자체가 인증 수단이니까, 유출되면 비밀번호 없이도 로그인된 상태를 그대로 가져갈 수 있다. 여기에 평문으로 두는 건 좀 아니었다.

expo-secure-store가 하는 일

expo-secure-store는 값을 OS가 제공하는 보안 저장소에 넣어준다. iOS는 Keychain, Android는 Keystore로 암호화된 SharedPreferences. 앱이 지워지면 같이 사라지고, 다른 앱에서는 접근할 수 없다.

npx expo install expo-secure-store

저장하고 읽기

API는 AsyncStorage랑 거의 똑같아서 옮기는 데 부담이 없다. setItemAsync, getItemAsync, deleteItemAsync 세 개만 알면 끝이다.

import * as SecureStore from 'expo-secure-store';

const TOKEN_KEY = 'access_token';

export async function saveToken(token: string) {
  await SecureStore.setItemAsync(TOKEN_KEY, token);
}

export async function getToken() {
  return SecureStore.getItemAsync(TOKEN_KEY); // 없으면 null
}

export async function clearToken() {
  await SecureStore.deleteItemAsync(TOKEN_KEY);
}

주의할 점 두 가지

1. 값은 문자열만. 객체를 넣으려면 JSON.stringify로 직렬화해야 한다. 그리고 iOS Keychain은 항목당 2KB 제한이 있으니 큰 데이터를 통째로 넣으면 안 된다. 토큰이나 API 키 정도만 넣고, 사용자 프로필 같은 건 MMKV나 SQLite로 보내는 게 맞다.

2. 생체인증과 묶을 수 있다. requireAuthentication 옵션을 주면 값을 읽을 때 Face ID / 지문을 요구한다. 지갑 앱처럼 민감한 값은 이렇게 잠가둘 수 있다.

await SecureStore.setItemAsync('pin', '1234', {
  requireAuthentication: true,
  keychainAccessible: SecureStore.WHEN_UNLOCKED,
});

keychainAccessible로 접근 시점도 정할 수 있는데, 기본값(WHEN_UNLOCKED)이면 기기가 잠겨 있을 때는 못 읽는다. 백그라운드에서 토큰을 읽어야 하는 경우가 있으면 이 옵션을 확인해봐야 한다.

정리

핵심은 간단하다. 토큰 같은 인증 정보는 SecureStore, 일반 데이터는 AsyncStorage나 MMKV. API가 거의 동일해서 기존 코드에서 저장소만 바꿔 끼우면 되니, 로그인 붙일 때 처음부터 이렇게 나눠두는 걸 추천한다. 나중에 옮기는 것보다 훨씬 편하다.