Email Claim 정책
/oauth/userinfo 가 emit 하는 email 은 그 시점 사용자의 대표 이메일(primary email) 입니다. 사용자가 로그인할 때 입력한 이메일이 아니며, RP 에 영구히 고정되는 값도 아닙니다.
| Claim | 타입 | 의미 |
|---|---|---|
email | string | 요청 시점의 대표 이메일. 가변(mutable) — 사용자가 logi 설정에서 언제든 변경 가능. |
email_verified | boolean | 대표 이메일이 검증된 소스(verified credential / Apple / Google SSO)에서 왔는지. |
핵심 규칙 세 가지:
email은 식별자가 아닙니다. 계정 키는 반드시sub를 쓰세요. 같은 사용자가 다음 로그인에서 다른email을 들고 올 수 있습니다.id_token에는 email claim 이 없습니다. email 이 필요하면 userinfo 를 호출하세요.- 로그인에 쓴 이메일 ≠ email claim. logi 계정에는 여러 로그인 이메일(legacy email_address, Apple/Google/Kakao SSO 이메일, verified 추가 이메일)이 붙을 수 있고, 어느 것으로 로그인하든 userinfo 는 항상 대표 이메일을 내보냅니다.
email 은 언제 바뀌나
- 사용자가 logi 앱 설정 → 계정 → 이메일 에서 대표 이메일을 변경했을 때.
- SSO(Apple/Google)로 가입한 계정은 가입 순간 대표 이메일이 해당 SSO 이메일로 명시적으로 고정됩니다 (2026-06-11 정책). 이후 변경은 위의 명시적 선택뿐 — 이메일을 추가했다고 해서 RP 에 나가는 값이 소리 없이 바뀌지 않습니다.
- Apple Hide-My-Email 사용자는 relay 주소(
@privaterelay.appleid.com)가 올 수 있습니다. Apple 이 전달을 보증하므로email_verified: true입니다.
RP 는 Snapshot 과 Follow 중 하나를 명시적으로 선택하세요
대부분의 OAuth 예제 코드는 email 을 계정 생성 시 한 번만 저장합니다. 그 결과 아무 결정 없이 암묵적으로 Snapshot 이 되고, 사용자가 logi 에서 대표 이메일을 바꿔도 RP 화면에는 영원히 가입 시점 이메일이 남습니다 — 사용자는 이걸 버그로 인식합니다. 어느 쪽이든 의도를 코드와 문서에 남기세요.
전략 A — Follow (권장): 다음 로그인부터 IdP 를 따라간다
매 SSO 로그인 시 email_verified: true 인 email 이 로컬 저장값과 다르면 갱신합니다. 백그라운드 동기화가 아니라 로그인 시점 반영이므로 추가 인프라가 필요 없습니다.
ruby
# sub 매칭으로 기존 user 를 찾은 직후 (예: Rails)
def refresh_email_if_changed!(user)
return unless @email.present? && @email_verified
return if user.email_address == @email
# 다른 로컬 계정이 이미 그 주소를 쓰면 갱신을 건너뛴다 (로그인은 계속 진행)
if User.where.not(id: user.id).where(email_address: @email).exists?
Rails.logger.warn("[sso] email refresh skipped: collision user=#{user.id}")
return
end
user.update!(email_address: @email)
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
# 동시 가입이 주소를 선점한 TOCTOU — 갱신은 best-effort, 로그인은 깨지 않는다
Rails.logger.warn("[sso] email refresh skipped: race user=#{user.id}")
end주의사항:
- logi(one_pass) provider 에만 적용하세요. Apple 로그인에 같은 패턴을 쓰면 Hide-My-Email relay 주소가 실제 이메일을 덮어쓸 수 있습니다.
email_verified: false인 값은 절대 채택하지 마세요 (계정 탈취 벡터).- 로컬 이메일이 비밀번호 로그인 식별자를 겸한다면, 갱신 후 사용자는 새 이메일 + 기존 비밀번호로 로컬 로그인하게 됩니다. IdP 가 source of truth 라는 의도된 동작이지만, 사용자 안내가 필요할 수 있습니다.
전략 B — Snapshot: 가입 시점 값을 표시 전용으로 보존
email 을 연락·표시 용도로만 쓰고 사용자가 RP 안에서 직접 관리하게 한다면 Snapshot 도 정당합니다. 단:
- 코드에 "의도적으로 동기화하지 않는다" 주석을 남기세요.
- RP 안에 이메일 수정 UI 를 제공하거나, 최소한 "logi 에서 바꿔도 여기 반영되지 않는다" 를 사용자가 알 수 있게 하세요.
함정 정리
| 함정 | 결과 | 회피 |
|---|---|---|
| email 로 계정 키잉 | 대표 변경 시 같은 사람이 새 계정으로 분리 | sub 키잉 (Sub 정책) |
| 암묵적 Snapshot | "대표 바꿨는데 RP 에 반영 안 됨" 사용자 버그 리포트 | Follow 채택 또는 Snapshot 명시 |
| unverified email 채택 | 타인 이메일 사칭 → 계정 연결 탈취 | email_verified == true 일 때만 소비 |
| Apple 에 Follow 적용 | relay 주소가 실이메일 덮어씀 | Follow 는 one_pass 전용 |
| 갱신 실패가 로그인 중단 | 동시성 race 로 로그인 500 | 갱신은 best-effort, rescue 후 진행 |
참고
- Sub 정책 (sub / canonical_sub) — 계정 키는 항상 sub
- 권장 아키텍처 (RP 통합)
- Scopes —
emailscope 와 per-claim 동의 - Event Delivery (3-tier) — 로그인 없이도 변경을 받고 싶다면 webhook/polling 확장 여지 (email 변경 이벤트는 현재 미발행)