Expo SecureStore 사용법: 토큰 저장과 생체 인증 처리

2026.04.21·수정 2026.09.13·약 18분·작성: 해비·블로그 소개

학습 목표·선수 지식: 작은 인증 값을 저장·삭제하고 저장 실패와 생체 인증 취소를 구분합니다. async/await와 서버 세션 갱신 개념을 알아야 합니다.

Expo SecureStore 토큰 저장 핵심 요약

SecureStore는 기기의 보호된 저장 기능을 이용해 작은 key-value 값을 암호화해 저장하지만, 완전한 비밀 저장소·서버 보안·백업·세션 폐기를 대신하지 않습니다. access/refresh token처럼 앱 재시작 뒤 필요한 작은 값만 저장하고, 서버에서 토큰 만료·회전·폐기를 강제하세요. 비밀번호, 서비스 계정 키, 대용량 프로필, 복구 불가능한 원본 데이터는 넣지 않습니다.

버전 기준: 2026-09-13 최신 공식 문서의 API를 대조했습니다. 고정 버전 숫자를 다른 프로젝트로 복사하지 말고 npx expo install로 현재 Expo SDK와 호환되는 의존성을 설치하고 lockfile을 보관하세요.

1. “암호화됨”보다 저장할 값과 공격 경계를 먼저 정합니다

Expo SecureStore에 저장할 토큰과 저장하면 안 되는 서버 비밀·대용량 데이터를 나누는 기준
권장 이유·대안
짧은 access token 조건부 가능 만료를 짧게 하고 서버 검증·폐기 적용
refresh token 가능 회전·재사용 탐지·로그아웃 시 서버 폐기 필요
앱 잠금용 작은 secret 조건부 가능 생체 인증 실패·무효화 fallback 설계
사용자 비밀번호 저장 금지 인증 공급자의 token/session 사용
서비스 계정·API master key 저장 금지 서버 비밀 관리자에 보관, 앱에 배포하지 않음
대용량 프로필·복구 불가능한 원본 저장 금지 플랫폼이 큰 값을 거부할 수 있고 backup이 아님

SecureStore에서 값을 읽은 뒤에는 JavaScript 메모리와 앱 로직이 그 평문을 사용할 수 있습니다. 기기가 root/jailbreak 되었거나 실행 중인 앱 프로세스가 침해된 상황까지 절대 방어한다고 단정할 수 없습니다. 어떤 공격을 줄이려는지와 토큰이 탈취됐을 때 서버가 무엇을 할 수 있는지 함께 설계합니다.

2. Android와 iOS의 저장·삭제 동작은 같지 않습니다

Android

Expo 공식 문서상 값은 SharedPreferences에 저장되고 Android Keystore의 키로 암호화됩니다. 앱을 제거하면 Keystore 항목이 사라지므로 SecureStore 값도 보존되지 않습니다. 복원된 backup은 기존 키로 해독할 수 없기 때문에 config plugin이 해당 항목을 Android Auto Backup에서 제외하도록 구성합니다. Keystore의 키 추출 방지와 사용 제한 원리는 Android Keystore 공식 문서에서 확인하세요.

iOS

iOS에서는 Keychain의 generic password 항목을 사용합니다. 같은 bundle ID로 재설치할 때 값이 남을 수 있지만 Expo는 이 동작을 보장되는 영구 저장으로 의존하지 말라고 안내합니다. 로그아웃·계정 삭제 때 앱이 명시적으로 값을 지우고, 재설치 뒤 남은 credential도 서버가 유효성을 다시 판단해야 합니다. 기반 API는 Apple Keychain Services입니다.

상황 Android iOS 앱 처리
앱 업데이트 일반적으로 유지 일반적으로 유지 스키마·key migration 테스트
앱 삭제·재설치 보존되지 않음 남을 수 있으나 보장 아님 서버 재검증, signed-out fallback
생체 정보 변경 requireAuthentication 값이 무효화될 수 있음 null·오류를 로그아웃 또는 재인증으로 처리

3. 설치와 config plugin

npx expo install expo-secure-store

설정 예시: app.json의 해당 객체에 병합합니다. 기존 설정은 보존합니다.

{
  "expo": {
    "plugins": [
      [
        "expo-secure-store",
        {
          "configureAndroidBackup": true,
          "faceIDPermission": "보호된 세션을 확인하기 위해 Face ID를 사용합니다."
        }
      ]
    ],
    "ios": { "config": { "usesNonExemptEncryption": false } }
  }
}

Face ID 설명 문구와 Android backup 설정은 native binary에 반영되므로 config를 바꾼 뒤 새 development build·production build를 만들어야 합니다. 생체 인증이 필요한 requireAuthentication 흐름은 Expo Go가 아니라 config가 포함된 실제 build와 실기기에서 확인합니다. 정확한 현재 옵션은 Expo SecureStore 공식 문서를 기준으로 합니다.

4. 토큰 저장·복원·삭제를 한 모듈로 묶습니다

Expo SecureStore 토큰 저장, 서버 refresh, 회전, 로그아웃 삭제와 실패 복구 흐름

저장 API를 화면에서 직접 흩어 쓰지 않습니다

src/auth/sessionStore.ts 전체 파일입니다. access/refresh 토큰을 두 개의 병렬 write로 나누면 한쪽만 성공할 수 있어 여기서는 작은 session JSON 한 건으로 묶습니다. 이것이 서버 갱신과의 분산 트랜잭션을 보장하지는 않습니다. 기존 두 key에서 이전할 경우 명시적 마이그레이션과 이전 key 삭제가 필요합니다. 저장 호출의 reject를 화면에서 처리하고 영속 저장 실패를 로그인 성공으로 표시하지 마세요.

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

const SESSION_KEY = 'auth.session';
export type Session = { accessToken: string; refreshToken: string };

export async function saveSession(accessToken: string, refreshToken: string) {
  await SecureStore.setItemAsync(
    SESSION_KEY,
    JSON.stringify({ accessToken, refreshToken }),
  );
}

export async function readRefreshToken() {
  const raw = await SecureStore.getItemAsync(SESSION_KEY);
  if (raw === null) return null;
  let parsed: unknown;
  try {
    parsed = JSON.parse(raw);
  } catch {
    await clearSession();
    return null;
  }
  if (
    typeof parsed !== 'object' ||
    parsed === null ||
    !('refreshToken' in parsed) ||
    typeof parsed.refreshToken !== 'string'
  ) {
    await clearSession();
    return null;
  }
  return parsed.refreshToken || null;
}

export async function clearSession() {
  await SecureStore.deleteItemAsync(SESSION_KEY);
}

key 이름과 옵션을 중앙 모듈에 두면 저장할 때와 읽을 때 다른 keychainService·인증 옵션을 쓰는 실수를 줄일 수 있습니다. 로그에는 토큰 값이나 SecureStore 오류 객체의 민감한 부가정보를 출력하지 않습니다.

앱 시작 시 저장 값만 보고 로그인 완료로 판단하지 않습니다

src/auth/restoreSession.ts 전체 모듈입니다. 서버 호출 함수는 인자로 주입하므로 미정의 함수를 숨기지 않습니다. 실제 구현은 공급자의 응답을 아래 오류 계약으로 변환해야 합니다. HTTP 403 전체를 무조건 토큰 무효로 보지 말고 서비스가 정의한 credential 오류만 정규화하세요. 네트워크 실패에서는 기존 값을 보존합니다.

import {
  clearSession,
  readRefreshToken,
  saveSession,
  type Session,
} from './sessionStore';

type RefreshFailure = Error & { status?: number; code?: string };

const INVALID_REFRESH_CODES = new Set([
  'invalid_grant',
  'invalid_token',
  'token_revoked',
]);

function isRefreshCredentialInvalid(error: unknown) {
  if (typeof error !== 'object' || error === null) return false;
  const failure = error as RefreshFailure;
  return failure.status === 401 || INVALID_REFRESH_CODES.has(failure.code ?? '');
}

export async function restoreSession(
  exchangeRefreshToken: (value: string) => Promise<Session>,
) {
  const refreshToken = await readRefreshToken();
  if (!refreshToken) return { kind: 'signed-out' } as const;

  try {
    const session = await exchangeRefreshToken(refreshToken);
    await saveSession(session.accessToken, session.refreshToken);
    return { kind: 'signed-in', session } as const;
  } catch (error) {
    if (isRefreshCredentialInvalid(error)) {
      await clearSession();
      return { kind: 'signed-out' } as const;
    }

    // network·timeout·429·5xx는 token을 보존하고 상위 재시도 정책에 맡깁니다.
    throw error;
  }
}

저장된 refresh token이 있어도 만료·폐기·재사용 탐지·계정 정지 상태일 수 있습니다. 서버 교환이 성공한 뒤 session을 복원하되, 모든 실패를 로그아웃으로 취급하면 안 됩니다. 위 예제는 exchangeRefreshToken이 서비스 계약에 따라 “refresh credential 무효”를 401 또는 명시적 code로 정규화한다고 가정합니다. OAuth 2.0 규격의 invalid_grant도 만료·폐기·유효하지 않은 refresh token을 뜻합니다. 이때만 로컬 token을 지우고, network·timeout·429·5xx는 token을 보존한 채 상위로 전달해 제한된 재시도·backoff·offline UI를 적용합니다. 근거는 OAuth 2.0 token endpoint 오류 규격, 앱 인증 흐름은 Expo 인증 가이드를 참고하세요.

5. 생체 인증은 저장 암호화와 별도의 사용 조건입니다

src/auth/protectedStore.ts 전체 모듈입니다. 이 별도 키는 앞 sessionStore와 자동으로 연결되지 않습니다. 읽기 취소·시스템 실패는 unavailable로, 값 없음·무효화는 missing으로 구분합니다. 취소했다고 즉시 서버 세션을 폐기하지 말고 재시도·다른 로그인 경로를 제공하세요. 생체 인증을 선택했다가 사용 불가 상태가 되었다고 일반 저장으로 조용히 낮춰 저장하지 않습니다.

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

const PROTECTED_KEY = 'auth.protected-refresh-token';
const protectedOptions = {
  requireAuthentication: true,
  authenticationPrompt: '세션을 계속하려면 본인 확인이 필요합니다.',
  keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
} as const;

export async function saveProtectedToken(value: string) {
  if (!SecureStore.canUseBiometricAuthentication()) return false;
  await SecureStore.setItemAsync(PROTECTED_KEY, value, protectedOptions);
  return true;
}

export async function readProtectedToken() {
  try {
    const value = await SecureStore.getItemAsync(PROTECTED_KEY, protectedOptions);
    return value === null
      ? ({ kind: 'missing' } as const)
      : ({ kind: 'value', value } as const);
  } catch {
    return { kind: 'unavailable' } as const;
  }
}

canUseBiometricAuthentication()은 충분히 안전한 생체 인증 사용 가능 여부를 확인합니다. requireAuthentication은 플랫폼별 prompt 시점이 다르고, 새 지문·얼굴 등록처럼 생체 설정이 바뀌면 저장 key가 무효화되어 읽기 결과가 null이 될 수 있습니다. 생체 인증 실패를 데이터 손실 오류로만 처리하지 말고 비밀번호·OAuth 재로그인 같은 복구 경로를 제공합니다.

동기식 getItem·setItem은 인증 prompt 동안 JavaScript thread를 막을 수 있으므로 화면 흐름에서는 async API를 우선 검토합니다. 실제 기기의 prompt, 취소, 여러 번 실패, 생체 정보 변경을 모두 테스트하세요.

6. 저장소 한계와 실기기 검증표

Expo 문서는 큰 payload를 underlying platform이 거부할 수 있고, 과거 일부 iOS에서 약 2,048바이트를 넘는 값이 거부된 사례를 설명합니다. 이 숫자를 보장 한도로 사용하지 말고 토큰처럼 작은 문자열만 저장하며 모든 write 오류를 처리합니다.

7. 명시적 결론과 다음 행동

결론: SecureStore는 작은 credential의 로컬 노출 위험을 줄이는 도구이며 인증 시스템 자체가 아닙니다. 서버의 짧은 만료·refresh token 회전·폐기와 앱의 실패 복구가 함께 있어야 합니다. 다음 행동은 저장 중인 key 목록을 작성해 토큰 외 데이터를 제거하고, Android/iOS 삭제·재설치·생체 변경 테스트를 실제 기기에서 수행하는 것입니다.

공식 근거

내부 학습 경로

문서·검증 기준 (2026-09-13): 해당 기능의 Expo 공식 문서와 코드 구조를 대조했습니다. 설치 버전은 프로젝트 Expo SDK에 맞는 npx expo install 결과와 lockfile을 기준으로 합니다. 이번 개정에서는 실제 Android·iOS 기기 실행, 원격 EAS 빌드, 외부 인증 서버 연동을 수행하지 않았습니다. 본문의 기기 동작은 독자가 확인할 기대 결과입니다.

이 글이 도움이 되었나요?

조회 중

Expo 학습 순서

필수 9개 · 전체 13개

읽음 기록 관리

전체 과정 목차 (13개)
  1. 필수 학습 · Expo 첫 앱 만들기: 설치·프로젝트 생성부터 첫 화면 실행까지
  2. 필수 학습 · Expo Safe Area 사용법: 화면 여백을 안전하게 잡는 방법
  3. 필수 학습 · Expo Status Bar 사용법: Safe Area와 화면별 설정
  4. 필수 학습 · Expo vector icons 사용법: 탭바와 커스텀 아이콘 적용하기
  5. 필수 학습 · Expo KeyboardAvoidingView 사용법: 키보드가 화면을 가릴 때 해결하기
  6. 필수 학습 · Expo Router 사용법: app 폴더와 Stack Tabs 구조 잡기
  7. 필수 학습 · Expo WebBrowser 사용법: 외부 링크와 로그인 복귀 처리
  8. 필수 학습 · Expo SecureStore 사용법: 토큰 저장과 생체 인증 처리 현재 글
  9. 선택 참고 · Expo Location 사용법: 현재 위치와 백그라운드 추적 처리
  10. 선택 참고 · Expo React Native Web 사용법: 앱을 웹으로 확장하기
  11. 선택 참고 · Expo Metro unable to resolve module 오류 해결: 경로와 캐시
  12. 필수 학습 · Expo EAS Build 시작하기: Android APK 빌드부터 설치 확인까지
  13. 선택 참고 · Expo EAS Build 오류 해결: 로컬은 되는데 원격 빌드만 실패할 때

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기