어느 날 갑자기 Google 로그인이 안 된다는 제보가 들어왔다.
로컬에서는 잘 됐는데, AOS 빌드 후 서버에서 인증이 계속 실패하는 것이다.
서버 로그에는 이게 찍혔다:
InvalidExternalProviderTokenException
서버 코드를 열어보니 이런 구조였다.
// google.provider.ts
return await jwtVerify(idToken, publicKeys, {
issuer: "https://accounts.google.com",
audience: [
process.env.AUTH_GOOGLE_CLIENT_ID, // 웹 Client ID
process.env.AUTH_GOOGLE_IOS_CLIENT_ID // iOS Client ID
],
algorithms: ["RS256"]
})
.catch(() => {
throw new InvalidExternalProviderTokenException(); // ← 여기서 에러 발생
});
JWT를 검증할 때 audience 목록과 토큰의 aud 값이 다르면 예외를 던지는 구조.
즉, Android에서 전혀 다른 값이 aud에 담겨서 오고 있었던 것이다.
결론부터 말하면:
src/main/res/values/strings.xml의default_web_client_id에 잘못된 Client ID가 들어가 있었다.
정확히는, 올바른aud용 값(Web Client ID)이 아닌 테스트 환경의azp값이 들어가 있었던 것.
에러가 터지기까지의 흐름을 추적하면 이렇다:
[이전 작업자]
google-services.json에서
Web Client ID(type:3) 대신
Android Client ID(type:1)를 strings.xml에 기입
│
▼
[Android SDK]
requestIdToken(serverClientId) 호출 시
잘못된 값을 Google에 전달
│
▼
[Google 서버]
받은 serverClientId를
그대로 ID Token의 aud에 담아 발급
│
▼
[API 서버]
허용된 Client ID 목록 ≠ aud 값
→ JWT 검증 실패 💥
이 에러를 제대로 이해하려면 OAuth 기본 구조를 알아야 한다.
나도 이번 계기로 처음 제대로 공부했다.
OAuth 2.0 : 사용자가 비밀번호를 제3자 서비스에 직접 넘기지 않고, Google·카카오 같은 기존 계정으로 안전하게 인증하는 개방형 표준 프로토콜
| 구성 요소 | 역할 | 이번 케이스에서 |
|---|---|---|
| Resource Owner | 로그인하려는 실제 사용자 | 앱을 사용하는 유저 |
| Client | 우리가 만든 앱 | Android 앱 |
| Authorization Server | 권한을 부여하는 서버, Token 발급 | Google 서버 |
| Resource Server | 사용자 정보를 실제로 가진 서버 | Google (프로필 등) |
| Access Token | 리소스 접근 권한 자격증명 | Google이 발급 |
| ID Token (JWT) | 사용자 신원 증명 토큰 | aud, sub 등 claim 포함 |
전체 흐름을 단계별로 보면 이렇다.
사용자 Android Native Google 서버 우리 API 서버
│ │ │ │
│ 로그인 버튼 클릭 │ │ │
│──────────────────►│ │ │
│ (WebView 내 UI) │ │ │
│ │ │ │
│ │ ① 구글 로그인 요청 │ │
│ │ (serverClientId │ │
│ │ = string.xml 값) │ │
│ │───────────────────►│ │
│ │ │ │
│ │ │ ② 사용자 Google 계정 │
│ │◄── 로그인 팝업 ────│ 인증 (구글 UI) │
│ │ │ │
│ │ ③ ID Token 발급 │ │
│ │ aud = serverClientId │
│ │◄───────────────────│ │
│ │ │ │
│ │ ④ ID Token 전달 │ │
│ │──────────────────────────────────────────►│
│ │ POST /auth/google/app │
│ │ body: { token: "eyJ..." } │
│ │ │ │
│ │ │ ⑤ Public Key 조회 │
│ │ │◄─────────────────────│
│ │ │ GET /oauth2/v3/certs│
│ │ │─────────────────────►│
│ │ │ │
│ │ │ ⑥ JWT 검증 │
│ │ │ - 서명 유효? │
│ │ │ - iss 맞음? │
│ │ │ - aud 허용 목록에? │
│ │ │ - 만료 안됨? │
│ │ │ │
│ │ │ ⑦ 사용자 조회/생성 │
│ │ │ DB에서 sub로 찾기 │
│ │ │ │
│ │ ⑧ 우리 서비스 Access Token 반환 │
│ │◄──────────────────────────────────────────│
│ │ │ │
│ 로그인 완료 │ │ │
│◄──────────────────│ │ │
strings.xml의 default_web_client_id가 여기 쓰인다.serverClientId를 그대로 ID Token의 aud에 담아준다. aud가 서버 허용 목록에 없으면 → 예외 발생이 에러의 본질은 aud claim이 어디서 오는지 몰랐기 때문이다.
| Claim | 의미 | 출처 | 비고 |
|---|---|---|---|
iss | "Google이 발급했어" | Google 고정값 | Android 저장 불필요 |
azp | "Android 앱이 요청했어" | google-services.json client_type: 1 | SDK가 자동 세팅 |
aud ⚠️ | "이 토큰을 쓸 서버야" | strings.xml → default_web_client_id | 에러 원인. type:3 값을 넣어야! |
sub | "이 사용자 고유 번호" | 로그인 성공 후 Google 런타임 반환 | DB 저장용 식별자 |
azp vs audazp = "요청한 앱이 누구냐" → Android Client ID (google-services.json type:1, SDK 자동 세팅)
aud = "이 토큰을 쓸 서버가 누구냐" → Web Client ID (google-services.json type:3, 내가 직접 설정)
둘 다 xxx.apps.googleusercontent.com 형식이라 처음엔 구분하기 어려웠다.
google-services.json에서 올바른 값 찾는 법{
"oauth_client": [
{
"client_id": "123-androidXXX.apps.googleusercontent.com",
"client_type": 1 // Android → azp에 자동 세팅됨. strings.xml에 넣으면 ❌
},
{
"client_id": "123-iosXXX.apps.googleusercontent.com",
"client_type": 2 // iOS
},
{
"client_id": "123-webXXX.apps.googleusercontent.com",
"client_type": 3 // ✅ 이걸 default_web_client_id에 넣어야 한다!
}
]
}
| 구분 | ❌ 잘못된 경우 | ✅ 올바른 경우 |
|---|---|---|
default_web_client_id 값 | type:1 Android Client ID | type:3 Web Client ID |
ID Token의 aud | 123-android~.apps.googleusercontent.com | 123-web~.apps.googleusercontent.com |
| 서버 검증 결과 | audience mismatch → 예외 발생 💥 | 허용 목록과 일치 → 통과 ✅ |
google-services.json에서 Web Client ID 확인Firebase Console 또는 Google Cloud Console에서 최신 google-services.json을 다운로드한 뒤,
client_type: 3인 항목의 client_id를 복사한다.
strings.xml 수정<!-- src/main/res/values/strings.xml -->
<resources>
<!-- ❌ 잘못된 예: Android Client ID나 azp 값을 넣은 경우 -->
<!-- <string name="default_web_client_id">123-androidXXX.apps.googleusercontent.com</string> -->
<!-- ✅ 올바른 예: Web Client ID (client_type: 3) -->
<string name="default_web_client_id">123-webXXX.apps.googleusercontent.com</string>
</resources>
# 서버의 AUTH_GOOGLE_CLIENT_ID가 위 Web Client ID와 동일한지 반드시 확인
AUTH_GOOGLE_CLIENT_ID=123-webXXX.apps.googleusercontent.com
확인하고 싶다면 jwt.io에서 ID Token을 붙여넣어 aud claim을 직접 확인할 수 있다.
{
"iss": "https://accounts.google.com",
"azp": "123-androidXXX.apps.googleusercontent.com", // Android가 요청했다는 표시
"aud": "123-webXXX.apps.googleusercontent.com", // ← 이 값이 서버 허용 목록에 있어야 함
"sub": "1234567890",
...
}
□ strings.xml의 default_web_client_id가 google-services.json client_type:3 값과 일치하는가?
□ 서버의 AUTH_GOOGLE_CLIENT_ID 환경변수가 Web Client ID(type:3)로 설정되어 있는가?
□ jwt.io에서 ID Token의 aud 값을 직접 확인했는가?
□ 테스트 환경과 프로덕션 환경의 google-services.json을 혼용하고 있지는 않은가?
□ iOS Client ID가 있다면 서버 audience 배열에 추가되어 있는가?
□ Firebase Auto-fill로 생성된 값이 나중에 다른 값으로 덮어써지지 않았는가?
💡 aud는 내가 만드는 값이다
Google이 알아서 넣어주는 게 아니라, Android SDK가 requestIdToken(serverClientId)에 넘긴 값을 그대로 aud에 박아준다. 내가 잘못된 값을 넣으면 잘못된 aud가 생긴다.
🔍 azp와 aud는 완전히 다르다
azp는 "요청한 앱"(Android, 자동 세팅), aud는 "토큰을 쓸 서버"(수동 설정). 둘 다 Client ID처럼 생겼지만 역할이 전혀 다르다.
📄 google-services.json의 client_type이 핵심이다
type:1(Android), type:2(iOS), type:3(Web). serverClientId에는 반드시 type:3 값을 써야 한다.