도메인: Firebase App Check (앱 무결성 검증)
왜 필요한가
위협 모델
공시한입의 LLM 호출은 공시당 1회 (스케줄러가 분석 시) — 사용자 reveal 은 캐시된 결과 DB 조회라 호출당 LLM 비용이 추가로 발생하지 않는다. 그래서 위협은 "compute 비용 폭증" 이 아니라 수익 funnel 우회 + 다중 계정 어뷰즈 다.
- ad_free_pass IAP / 광고 시청 funnel 우회 (cap 무력화)
- 다중 계정 어뷰저 / 봇이 정상 분포를 흐려 정책 시그널 왜곡
- AdMob SSV legacy claim 경로 우회 (
/quota/ad-reward직접 호출)
| 공격 | 방법 | App Check 없을 때 | App Check 있을 때 |
|---|---|---|---|
| JWT 탈취 + 직접 API 호출 | JWT 추출 후 /quota/ad-reward 에 반복 호출 | quota 무한 증가 (used_count 무한 음수) | prod 에서 404 (경로 차단), SSV 만 허용 |
| 앱 리패키징 | AdMob SDK 제거 + 커스텀 앱 빌드 후 배포 | SSV 콜백 없이 클라이언트 claim 가능 | 변조 앱 서명 불일치 → token 발급 불가 |
| 루팅/탈옥 기기 | Play Integrity/AppAttest 우회 후 앱 수정 | reveal 무제한 | strict 모드: 403 |
| 에뮬레이터 자동화 | 가상 기기 대량 생성 | 계정당 월 200 × N 계정 | Play Integrity MEETS_BASIC_INTEGRITY 실패 |
AdMob SSV만으로 커버되지 않는 부분: SSV는 Google이 콜백을 직접 보내므로 클라이언트 위변조와 무관하다. 하지만 /quota/ad-reward legacy path와 reveal 자체가 뚫려있으면 광고를 안 봐도 quota를 늘릴 수 있다.
동작 원리
전체 플로우
sequenceDiagram
participant App as Flutter 앱
participant Firebase as Firebase
participant BE as dartbrief 백엔드
Note over App: 1. 앱 시작 시 App Check 초기화<br/>Android: Play Integrity API<br/>iOS: AppAttest<br/>→ 디바이스 + 앱 서명 검증
Note over App: 2. reveal 요청 직전<br/>getToken() 호출
App->>Firebase: 토큰 요청
Firebase-->>App: 서명된 JWT (1시간 TTL, SDK가 자동 캐시)
App->>BE: 3. POST /ai/analyze/{id}/reveal<br/>Authorization: Bearer $JWT<br/>X-Firebase-AppCheck: $appCheckToken
BE->>Firebase: 4. App Check 토큰 검증<br/>JWK endpoint에서 공개키 fetch (6h 캐시)<br/>RS256 서명 검증 / iss·aud 클레임 확인
Firebase-->>BE: 공개키 응답
Note over BE: 5. 검증 통과 → reveal 처리
BE-->>App: 응답토큰 구조 (Firebase App Check JWT)
Header: { "alg": "RS256", "kid": "<key_id>", "typ": "JWT" }
Payload:
{
"iss": "https://firebaseappcheck.googleapis.com/532121942538",
"aud": ["projects/532121942538", "projects/dartbrief"],
"sub": "1:532121942538:android:5651b10c890382b7b4bdf3", // App ID
"exp": <unix_timestamp>,
"iat": <unix_timestamp>
}백엔드 검증 체크리스트:
typ="JWT"(헤더)- RS256 서명 → Firebase JWK 공개키로 검증
exp만료 여부iss="https://firebaseappcheck.googleapis.com/532121942538"aud에"projects/532121942538"또는"projects/dartbrief"포함sub(App ID) ∈FIREBASE_ALLOWED_APP_IDSallowlist (strict 모드)
플랫폼별 Provider
| 플랫폼 | Provider | 검증 내용 | OS 요구사항 |
|---|---|---|---|
| Android (prod) | Play Integrity | 앱 서명 + 기기 무결성 + Google Play 설치 여부 | Android 5.0+ |
| iOS (prod) | AppAttest | 앱 서명 + 정품 Apple 기기 | iOS 14+ |
| Dev (양 플랫폼) | Debug | 항상 토큰 발급 (개발용 DebugToken) | 제한 없음 |
App Check가 막는 것:
- 리패키징된 앱 (서명 불일치)
- 루팅된 기기 (Play Integrity MEETS_BASIC_INTEGRITY 실패)
- 에뮬레이터 (Play Integrity MEETS_DEVICE_INTEGRITY 실패)
- AppAttest 우회 시도
App Check가 막지 못하는 것:
- 기기에서 직접 트래픽 스니핑 (HTTPS 레벨 공격은 별도 SSL Pinning 필요)
- 탈옥 기기 중 AppAttest 우회 성공 케이스 (iOS 제한적)
구현 상태
보호 대상 엔드포인트 (opt-out 모델, 2026-05-09 PR #59)
App Check 는 글로벌 미들웨어 체인 노드로 한 번만 끼워져 있어 모든 엔드포인트가 기본 보호된다. 새 보호 라우트 추가 시 wrap 누락 위험 X. 면제는 아래 매트릭스만:
| 면제 트리거 | 조건 | 이유 |
|---|---|---|
/actuator/* | 모든 메서드 | 외부 모니터링이 호출, 클라 토큰 없음 |
/api/v1/admob/ssv | 모든 메서드 | Google AdMob 서버→서버 (ECDSA 서명으로 별도 검증) |
/api/v1/admin/* | 모든 메서드 | admin 토큰으로 별도 인증 |
X-Worker-Secret 헤더 매칭 | GET 만 | 웹 (CF Workers SSR) 의 대안 ACL — AppCheck 토큰 발급 불가능한 SSR 컨텍스트 |
경로 자체로는 더 이상 면제 X — 모든 라우트가 기본 보호. 웹은 Worker shared secret 헤더로 인증 (모바일은 AppCheck 토큰, 웹은 secret — 둘 중 하나만 통과). secret 가 미설정 (WEB_SECRET="") 이면 헤더 검사 자체 비활성 (dev local 안전망).
웹 ACL 배경 (2026-05-10 PR #67): Next.js + CF Workers 배포 후 Firebase App Check 가 웹 fetch 를 막아서 SSR/ISR 응답 모두 403. 웹은 Play Integrity / AppAttest 둘 다 발급 불가 (모바일 전용). 단순 경로 면제는 어뷰저가 같은 path 를 외부에서 호출 시 무방비 → Worker shared secret 으로 caller 식별. 어뷰즈 ROI 추가 차단:
- lazy auth — 비로그인 시
result필드 omit (한줄요약만) - HTTPCache TTL (30s ~ 10m) — 같은 URL 재호출 비용 0
- LLM 호출 0 (스케줄러가 사전 분석, reveal 은 캐시 조회만)
- secret 유출 시 손상 한정 — GET 만 면제, write 작업은 모바일 AppCheck 만 통과
Rate limit: Worker secret GET 트래픽은 일반 IP rate limit (RPS 2 / Burst 10) 우회 → SharedBucketRateLimit (RPS 50 / Burst 200) 단일 공유 버킷 적용. Registry 의 Anonymous helper 가 ctx.IsWorker 보고 자동 분기. CF Workers SSR subrequest 가 단일 IP 로 수렴하는 환경에서 cache miss 동시 발생 시 정상 사용자 429 폭주 방지.
추가로 핸들러 레벨 정책:
POST /api/v1/quota/ad-reward— APP_ENV 체크 (prod → 404, SSV 만 허용)
Enforcement 모드
Soft (현재, APP_CHECK_ENFORCEMENT=soft):
- 토큰 없음 → 로그 + 통과
- 토큰 있음 + 검증 실패 → 로그 + 통과
- 클라이언트 배포 중 호환성 유지 목적
Strict (APP_CHECK_ENFORCEMENT=strict):
- 토큰 없음 → 403
{"status":"FORBIDDEN","message":"App Check token required"} - 토큰 있음 + 검증 실패 → 403
{"status":"FORBIDDEN","message":"App Check token invalid"}
Strict 전환 절차
- 새 클라이언트 (App Check 포함) 앱스토어 배포 완료
- 구 클라이언트 사용률 충분히 낮아진 시점 확인 (Railway 로그 기준)
- Railway 환경변수
FIREBASE_ALLOWED_APP_IDS설정 (Android prod/dev, iOS prod/dev App ID 콤마 구분) - Railway 환경변수
APP_CHECK_ENFORCEMENT=strict변경 - 재배포 (코드 변경 없음)
백엔드 핵심 파일
| 파일 | 역할 |
|---|---|
backend/internal/appcheck/verifier.go | JWK fetch (6h TTL 캐시, 실패 시 1분 backoff) + RS256 JWT 검증 |
backend/internal/appcheck/middleware.go | Middleware(v, mode, next) — soft/strict 모드 분기. Registry helper 가 카테고리별로 wrap |
backend/pkg/route/registry.go | Authenticated / Anonymous / AuthIssue helper 가 AppCheck wrap. 다른 helper (Admin / AdMobSSV / Actuator) 는 면제 |
backend/internal/ai/handler.go | adReward — non-local/dev 환경 404 |
Flutter 핵심 파일
| 파일 | 역할 |
|---|---|
mobile/lib/firebase_options.dart | Firebase 프로젝트 연결 설정 |
mobile/lib/app/bootstrap.dart | Firebase 초기화 + App Check activate |
mobile/lib/shared/api/api_client.dart | prod 요청마다 X-Firebase-AppCheck 헤더 첨부 |
Edge Cases
토큰 발급 실패 시 (soft 모드)
getToken() 실패 (네트워크 없음, Firebase 장애 등) → on Exception catch (_) 로 무시 → 헤더 없이 요청 전송 → 백엔드 soft 모드에서 로그 + 통과.
사용자는 reveal이 정상 동작함. strict 모드로 전환 후엔 실패 → 403 → 앱에서 에러 처리 필요 (미구현, 추후 대응).
Firebase JWK 서버 장애 시
백엔드 캐시가 만료되지 않았으면 stale 키로 계속 검증. 캐시 만료 후 JWK 서버도 복구 전이면:
- stale 캐시에 키가 있으면 stale 키로 검증 (서명 유효하면 통과)
- stale 캐시에도 없으면 에러 → soft: 로그 + 통과 / strict: 503보다 안전하게 soft fallback 고려 필요
Dev 빌드에서 실기기 테스트
Debug provider는 GOOGLE_APP_CHECK_DEBUG_TOKEN 환경변수 또는 앱 로그에 출력된 debug token을 Firebase Console에 등록해야 한다. 미등록 시 debug provider도 token 발급 실패.
→ Firebase Console → App Check → 앱 선택 → Debug tokens → 추가
App Check 토큰 재사용 (alreadyConsumed)
Firebase App Check 토큰은 기본적으로 재사용 가능 (1시간 TTL). 단, exchangeAppCheckToken 엔드포인트 호출 시 1회용으로 소모 가능하나 현재 미사용. reveal은 멱등성을 DB (user_revealed_disclosures)로 보장하므로 토큰 재사용 허용해도 무방.
환경변수 요약
| 변수 | 기본값 | 설명 |
|---|---|---|
FIREBASE_PROJECT_NUMBER | 532121942538 | JWT iss/aud 검증용 프로젝트 번호 |
APP_CHECK_ENFORCEMENT | soft | soft 또는 strict |
FIREBASE_ALLOWED_APP_IDS | (없으면 전체 허용) | 허용할 Firebase App ID 콤마 구분 목록. strict 전환 전 반드시 설정. |
APP_ENV | (없으면 "" — 차단) | local = ad reward 허용. 미설정 시 adReward local 기본값 없이 fail-closed. |
관련 문서
- 위협 모델 / 정책 결정 →
../policies/monetization.md - AdMob SSV (1차 방어선) →
../api/admob-ssv.md - Firebase Console 등록 절차 →
../operations/app-store.md
법적 페이지 영향
Firebase App Check 는 앱 실행 시점에 디바이스 무결성 토큰 (Android Play Integrity / iOS App Attest) 을 발급받아 Google 서버와 통신한다. 영구 저장 X, 어뷰즈 차단 용도 한정이지만 "자동 수집되는 정보" 로 분류되어 다음 페이지에 명시 필수:
frontend/src/app/privacy/page.tsx— 제2조 자동 수집, 제5조 위탁, 제6조 국외 이전frontend/src/app/terms/page.tsx— 제9조·제12조 외부 서비스 목록
웹은 App Check 미적용 (대신 X-Worker-Secret 으로 Worker SSR ACL — docs/operations/cloudflare-deploy.md 참조).