이전 글에서 Flutter + Rails 앱의 세션 버그 3개를 고쳤다. 고치고 나니 궁금해졌다. 다른 프로젝트에도 같은 문제가 있지 않을까?

iOS 1.0 제출을 앞둔 7개 Flutter 앱을 대상으로 인증/보안 크로스 감사를 진행했다.


감사 결과 요약

프로젝트인증 방식결과
앱 A (부동산 계약서)자체 JWT + SecureStorage✅ 양호
앱 B (AI 여행)자체 JWT + SharedPreferences🔴 3건
앱 C (팀 관리)자체 JWT + SharedPreferences🔴 2건
앱 D (운세/MBTI)Firebase Auth + Supabase🔴 1건
앱 E (필름 스캐너)Supabase Auth✅ 양호
앱 F (AI 미디어)Supabase Auth✅ 양호
앱 G (음성 대화)-⏭️ 미확인

Supabase SDK가 인증을 관리하는 앱은 모두 양호했고, 자체 JWT 구현 앱에서만 문제가 있었다.


패턴 1: SharedPreferences에 토큰 평문 저장

SharedPreferences는 Android에서 XML, iOS에서 plist로 암호화 없이 저장된다. 앱 B, 앱 C에서 발견.

// ❌ SharedPreferences - 평문 저장
final prefs = await SharedPreferences.getInstance();
await prefs.setString('auth_token', token);

// ✅ FlutterSecureStorage - iOS Keychain / Android Keystore
const storage = FlutterSecureStorage();
await storage.write(key: 'auth_token', value: token);

수정은 내부 구현만 교체하고 API는 유지하여 호출부 변경을 최소화했다. bool 같은 비-String 타입은 value.toString() / value == 'true'로 변환.


패턴 2: 401 에러 시 토큰 갱신 없이 로그아웃

앱 B는 401을 받으면 토큰만 지우고 끝, 앱 C는 로그만 찍고 방치.

// ❌ 갱신 없이 토큰 삭제만
if (error.response?.statusCode == 401) {
  tokenStorage.clearTokens();  // 사용자는 다시 로그인해야 함
}

앱 B는 refresh token 갱신 → 원래 요청 재시도 → 실패 시 로그아웃 플로우를 추가했다.

// ✅ 401 → refresh 시도 → 재시도 → fallback
if (error.response?.statusCode == 401) {
  final refreshed = await _attemptTokenRefresh();
  if (refreshed) {
    final opts = error.requestOptions;
    opts.headers['Authorization'] = 'Bearer ${await _tokenStorage.getToken()}';
    return handler.resolve(await Dio().fetch(opts));
  }
  await _tokenStorage.clearTokens();
  _handleUnauthorized();
}

앱 C는 서버에 refresh 엔드포인트가 없어서 onUnauthorized 콜백으로 최소 대응.


패턴 3: PII가 SharedPreferences에 평문 저장

앱 D는 Firebase Auth로 인증 자체는 안전하지만, 게스트 사용자의 개인정보(생년월일, 성별, 이름)를 SharedPreferences에 저장하고 있었다. App Store 심사에서 개인정보 보호 위반으로 리젝될 수 있다.

// ❌ PII를 평문으로
await prefs.setString('guest_profile', jsonEncode({
  'birthDate': '1990-05-15', 'gender': 'male', 'name': '홍길동',
}));

// ✅ SecureStorage로 암호화
await storage.write(key: 'guest_profile', value: jsonEncode({...}));

교훈

자체 구현 vs SDK: 문제는 모두 자체 JWT에서 발생. SDK를 쓰면 저장/갱신/만료가 자동이다. 자체 구현 시 체크리스트:

  • SecureStorage 사용 여부
  • 401 시 refresh 시도 여부
  • 갱신 실패 시 로그아웃 처리
  • WebSocket 토큰 동기화

SharedPreferences 용도: 다크모드, 언어, 온보딩 같은 비민감 설정 값 전용. 토큰/PII는 절대 넣지 말 것.

같은 실수는 복제된다: 보일러플레이트 코드일수록 첫 번째 구현이 중요하다.

iOS 제출 전 한 줄 점검:

grep -r "SharedPreferences" --include="*.dart" lib/

이것만으로도 민감 데이터 평문 저장 여부를 빠르게 확인할 수 있다.