본문으로 건너뛰기
버전: 개발 버전 (최신)

앱 무결성 검증 API (v2)

앱 무결성 검증 API는 요청이 변조되지 않은 정품 앱에서, 정품 기기를 통해 왔는지를 플랫폼 네이티브 메커니즘으로 검증한다. iOS는 Apple App Attest, Android는 Google Play Integrity 를 사용한다. 본 endpoint(ios/attest/* · android/integrity/*) 의 spec 만 본 페이지에 두고, app-token 발급 등 사전 단계는 공통 사전 단계 페이지를 참조한다.

인증 정책 (v2 공통)
  • 인증 채널은 Authorization: Bearer <app-token> 한 줄.
  • deviceId 는 token payload 에서. body 에 deviceId 를 넣지 않는다.
  • 이 검증은 app-token 발급과 무관 — 검증 결과로 토큰을 발급하거나 그 발급을 게이트하지 않는다.
  • 모든 바이너리(토큰/서명/키)는 base64url 문자열. challenge/nonce 는 1회용 + 단기 TTL(기본 300s).
  • challenge/nonce 는 base64url 문자열로 전달하되, 서명·해시 입력은 base64url 디코드한 raw bytes 를 사용한다 — 클라이언트·서버가 동일 바이트에 적용해야 하며 문자열 자체에 서명/해시하지 않는다.
  • 검증 실패는 HTTP 200 + { verified: false, reason } 로 응답(4xx 아님). 단 body validation 실패는 400.
도입 배경 — Firebase App Check 대체 (BSI O.Resi_5)
  • 기존 Firebase App Check 를 iOS=App Attest / Android=Play Integrity 로 전환하기 위한 v2 API다. (BSI O.Resi_5 대응)
  • app-token(앱 토큰) 발급은 별도 메커니즘이다 — 앱 인증 API 참조.

0. 호출 순서

iOS (App Attest)

#APIAuthorization시점응답에서 추출할 값
1POST /v2/security/ios/attest/challengeBearer <app-token>키 등록·증명 직전challenge
2POST /v2/security/ios/attest/keyBearer <app-token>앱 첫 실행 1회registered
3POST /v2/security/ios/attest/verifyBearer <app-token>무결성 증명(최초 1회)verified

앱 첫 실행(설치 직후) 시 "키 등록(attest/key)" 과 "증명(attest/verify)" 을 한 번 수행하고, 이후에는 반복하지 않는다.

Android (Play Integrity)

#APIAuthorization시점응답에서 추출할 값
1POST /v2/security/android/integrity/challengeBearer <app-token>검증 직전nonce
2POST /v2/security/android/integrity/verifyBearer <app-token>무결성 토큰 검증(최초 1회)verified

challenge(1) 와 verify(2) 사이에 앱이 Play Integrity 로부터 무결성 토큰(nonce 포함)을 직접 받는다 — 앱↔Google 직접 통신으로 서버 API 가 아니다(아래 Android verify 참조).

Android 는 키 등록이 없다. 앱 첫 실행(설치 직후) 시 Google 이 발급한 무결성 토큰을 서버가 한 번 검증한다.

⭐ 표시 endpoint 가 본 검증의 본 호출. challenge/nonce 발급은 그 직전 사전 호출이다.

검증 원리 (개념)

  • App Attest (iOS) — 키 등록(attest) 시 "이 키가 정품 기기·정품 앱에서 생성됐다" 는 Apple CA 서명 증명서를 서버가 검증하고 public key 를 keyId 로 저장한다. 증명(assert) 시 저장한 public key 로 서명을 검증(위조·replay 차단)한다.
  • Play Integrity (Android) — 앱이 Play Integrity 로 받은 무결성 토큰을 서버가 검증하여 앱/기기/Play Protect 판정(verdict)을 확인한다.

공통 에러 (HTTP status)

무결성 검증 자체의 실패(attestation 거부)는 에러 status 가 아니라 HTTP 200 + { verified: false, reason } 로 응답한다(각 endpoint 의 Errors 표 참조). 그러나 아래 전송·인증 단계 에러는 표준 HTTP status 로 응답하며 모든 endpoint 공통이다(AppTokenGuard + body validation).

HTTPcodemessage발생 조건
4001001VALIDATION_ERRORbody 필드 누락/형식 오류 또는 unknown 필드 포함
4011000App token not provided / has expired / has been revoked / invalid format or signatureAppTokenGuard — app token 누락·만료·폐기·형식/서명 오류
500INTERNAL_SERVER_ERROR서버 내부 오류

iOS — App Attest

서명·인코딩 규약 (모바일↔서버 일치 필수)

키 등록(attest/key) 의 clientDataHash 는 SHA256(challenge 의 base64url 디코드 32바이트) 로 정의한다 — 클라이언트·서버가 디코드한 32바이트 에 동일하게 적용하며, 문자열 자체에 해시하지 않는다.

증명(attest/verify) 의 clientDataHash 는 SHA256(compact JSON {"appToken":"<app-token>"} 의 UTF-8 bytes ‖ challenge 의 base64url 디코드 bytes) 로 정의한다 — 공백 없는 compact JSON(Swift JSONEncoder 기본 출력과 동일) 바이트 뒤에 디코드한 challenge 바이트를 이어붙인 뒤 SHA256 한다. <app-token> 은 그 요청의 Authorization: Bearer 토큰과 동일한 문자열이다. challenge 는 1회용 재전송 차단인 동시에 clientData 의 일부로 서명 입력에 포함된다.

ECDSA 서명(assertion)은 ASN.1 DER(X9.62 Ecdsa-Sig-Value, 0x30 SEQUENCE prefix) 형식이다 — iOS Secure Enclave / Android Keystore 의 native 출력이자 WebAuthn ES256 표준이며, P1363(raw r‖s) 이 아니다. (native 2FA 서명 검증과 동일 규약 — libs/feature/auth/euNative2faSignatureVerifierServicedocs/security/bsi-audit-evidence/177-cryptography-justification.md 참조.)

1. POST /v2/security/ios/attest/challenge

🔗 라이브 명세 (Swagger UI): dev

challenge 를 발급한다. 키 증명(attestation)·어서션(assertion) 생성 시 clientDataHash 입력으로 사용된다.

  • Path: POST /v2/security/ios/attest/challenge
  • 인증: App Token 필요 (app token 검증)

Request

HeaderValue
AuthorizationBearer <app-token>

body 없음.

Response 200 OK

필드타입설명
challengestringbase64url 인코딩된 32바이트 난수
expiresInnumber만료(초)
{
"challenge": "qmF7c2k9Zk...",
"expiresIn": 300
}

2. POST /v2/security/ios/attest/key ⭐ (키 등록 · 첫 실행 시 1회)

🔗 라이브 명세 (Swagger UI): dev

앱 첫 실행 시 App Attest 키를 생성하고, 발급받은 challenge 로 키 증명(attestation) 객체를 만들어 전송한다. 가입/로그인 플로우와 분리된 독립 단계다.

  • Path: POST /v2/security/ios/attest/key
  • 인증: App Token 필요 (app token 검증)

Request

HeaderValue
AuthorizationBearer <app-token>
Content-Typeapplication/json
{
"keyId": "RkVCQ0Q...",
"attestationObject": "o2NmbXR...",
"challenge": "qmF7c2k9Zk..."
}
필드타입필수설명
keyIdstringYesApp Attest 키 식별자. generateKey() 가 돌려준 값을 그대로 전송 — base64 / base64url 모두 허용 (Apple 은 표준 base64 로 반환)
attestationObjectstringYes키 증명(attestation) 객체 (CBOR, base64url)
challengestringYes1 에서 받은 값

Response 200 OK

필드타입설명
registeredboolean등록 성공 여부
reasonstring | null실패 사유 (아래 Errors)
{
"registered": true
}

서버 검증 단계 (attestationObject)

  1. CBOR 디코드 → 인증서 체인 / authenticator data / public key 추출
  2. 인증서 체인을 Apple App Attest Root CA 까지 ECDSA(P-256) 서명 검증
  3. SHA256(challenge) 기반 nonce 가 인증서 확장 nonce 와 일치하는지 확인
  4. App ID 해시 = SHA256(TeamID.BundleID) 일치 확인
  5. AAGUID(환경) = appattest(운영) / appattestdevelop(개발) 확인
  6. public key 해시 = keyId 일치, counter = 0 확인
  7. 통과 시 public key + counter 를 keyId 로 저장

Errors

무결성 판정 실패는 HTTP 200 + { registered: false, reason } 로 응답한다. 전송·인증 단계 에러(400/401/500)는 공통 에러 참조.

reason발생 조건
CHALLENGE_EXPIREDchallenge TTL 만료
NONCE_MISMATCHchallenge 기반 nonce 가 인증서 확장 nonce 와 불일치
ATTESTATION_CHAIN_INVALID인증서 체인을 Apple App Attest Root CA 까지 검증 실패
KEY_ID_MISMATCHpublic key 해시가 keyId 와 불일치
APP_ID_MISMATCHApp ID 해시(SHA256(TeamID.BundleID)) 불일치
ENVIRONMENT_MISMATCHAAGUID(운영/개발 환경) 불일치

3. POST /v2/security/ios/attest/verify ⭐ (무결성 증명 · 최초 1회)

🔗 라이브 명세 (Swagger UI): dev

어서션(assertion)을 생성해 전송한다. clientDataHash 는 compact JSON {"appToken":"<app-token>"} (공백 없는 UTF-8) 바이트 뒤에 디코드한 challenge 바이트를 이어붙여 SHA256 한 값이며, <app-token>Authorization 헤더의 Bearer 토큰과 동일하다. challenge 는 재전송 차단인 동시에 clientData 의 일부다.

  • Path: POST /v2/security/ios/attest/verify
  • 인증: App Token 필요 (app token 검증)

Request

HeaderValue
AuthorizationBearer <app-token>
Content-Typeapplication/json
{
"keyId": "RkVCQ0Q...",
"assertion": "omlzaWdu...",
"challenge": "Zk93x2..."
}
필드타입필수설명
keyIdstringYes등록된 키 식별자 (base64/base64url)
assertionstringYes어서션(assertion) 객체 (CBOR, base64url). clientDataHash = SHA256({"appToken":"<app-token>"} ‖ 디코드(challenge)) 에 서명
challengestringYes1 에서 받은 값 (재전송 차단 + clientData 의 일부)

Response 200 OK

필드타입설명
verifiedboolean검증 성공 여부
reasonstring | null실패 사유
{
"verified": true
}

서버 검증 단계 (assertion)

  1. challenge 1회용 소비(재전송 차단). keyId 는 canonical base64url 로 정규화
  2. keyId 로 저장된 public key·counter 조회
  3. 저장된 public key 로 SHA256({"appToken":"<app-token>"} ‖ 디코드(challenge))(공백 없는 compact JSON 바이트 + 디코드한 challenge 바이트) 기반 서명을 ECDSA(P-256) 검증
  4. counter 가 저장값보다 큰지 확인(증가 안 했으면 replay) → counter 갱신

Errors

무결성 판정 실패는 HTTP 200 + { verified: false, reason } 로 응답한다. 전송·인증 단계 에러(400/401/500)는 공통 에러 참조.

reason발생 조건
CHALLENGE_EXPIREDchallenge TTL 만료
KEY_NOT_FOUNDkeyId 로 저장된 키 없음(키 등록 누락)
SIGNATURE_INVALID저장된 public key 로 서명 검증 실패
COUNTER_REGRESSIONcounter 가 증가하지 않음(replay 의심)

Android — Play Integrity

1. POST /v2/security/android/integrity/challenge

🔗 라이브 명세 (Swagger UI): dev

Play Integrity 토큰 요청에 사용할 nonce 를 발급한다.

  • Path: POST /v2/security/android/integrity/challenge
  • 인증: App Token 필요 (app token 검증)

Request

HeaderValue
AuthorizationBearer <app-token>

body 없음.

Response 200 OK

필드타입설명
noncestringbase64url 인코딩된 32바이트 난수
expiresInnumber만료(초)
{
"nonce": "bm9uY2Ux...",
"expiresIn": 300
}
중간 단계 — 무결성 토큰 발급 (앱↔Google 직접)

challenge 발급과 verify 사이에 앱이 Play Integrity 로부터 무결성 토큰을 직접 발급받는다(nonce 포함). 이 단계는 앱↔Google 직접 통신으로 서버 API 가 아니다.

2. POST /v2/security/android/integrity/verify ⭐ (최초 1회)

🔗 라이브 명세 (Swagger UI): dev

  • Path: POST /v2/security/android/integrity/verify
  • 인증: App Token 필요 (app token 검증)

Request

HeaderValue
AuthorizationBearer <app-token>
Content-Typeapplication/json
{
"integrityToken": "CgYI...",
"nonce": "bm9uY2Ux..."
}
필드타입필수설명
integrityTokenstringYesPlay Integrity 무결성 토큰
noncestringYes1 에서 받은 값

Response 200 OK

필드타입설명
verifiedboolean검증 성공 여부 (토큰 정통성 + nonce + 패키지 일치 기준)
verdictsobject | null평가 결과: { app, device, playProtect }
reasonstring | null실패 사유
{
"verified": true,
"verdicts": {
"app": "PLAY_RECOGNIZED",
"device": "MEETS_DEVICE_INTEGRITY",
"playProtect": "NO_ISSUES"
}
}
거부 정책 — 경고 후 진행(모니터링)

초기 단계에서는 DEVICE_INTEGRITY_FAILED / PLAY_PROTECT_RISK 같은 위험 판정을 차단 사유로 사용하지 않는다. 해당 verdict 은 verdicts 에 담아 반환·로깅하여 모니터링만 하며, verified 는 토큰 정통성·nonce·패키지 일치 기준으로 결정된다. 데이터를 충분히 모은 뒤 단계적으로 차단 정책으로 전환한다.

Errors

무결성 판정 실패는 HTTP 200 + { verified: false, reason } 로 응답한다. 전송·인증 단계 에러(400/401/500)는 공통 에러 참조.

reason발생 조건
NONCE_MISMATCH토큰의 nonce 가 발급한 nonce 와 불일치
TOKEN_DECODE_FAILED무결성 토큰 복호화/디코드 실패
APP_NOT_RECOGNIZED앱 인증서/버전 미인식
PACKAGE_MISMATCH패키지명 불일치

(※ DEVICE_INTEGRITY_FAILED, PLAY_PROTECT_RISK 는 현재 정책상 차단 사유 아님 — verdicts 로만 노출)


app-token 과의 관계

  • 이 API 는 무결성 검증 전용이며 app-token 발급과 분리되어 있다. 검증 성공이 app-token 을 발급하거나 그 발급을 게이트하지 않는다.
  • 검증 성공의 결과물은 verified: true 응답뿐이다.
  • app-token 은 기존 메커니즘으로 별도 발급·관리된다 — 앱 인증 API 참조.

다음 단계


변경 이력

버전날짜작성자변경 내용
0.1.02026-06-16jeff@weltcorp.com최초 작성 — App Attest/Play Integrity 무결성 검증 v2 (App Check 대체). sessionToken 폐기·app-token 분리·iOS 첫 실행 키 등록·Android 모니터링 정책 반영