이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • ID Token과 access token은 왜 서로 다른 목적으로 검증해야 하는가?
  • OIDC 로그인에서 issuer, audience, signature, expiry, nonce, subject는 각각 무엇을 확인하는가?
  • UserInfo 응답을 내부 계정과 연결할 때 어떤 조건을 확인해야 하는가?

개요

OIDC는 OAuth2 위에 사용자 인증 결과를 전달하는 identity layer다. 핵심 산출물은 ID Token이며, 이 token은 Authorization Server가 사용자를 인증했다는 claims를 담는다.

ID Token은 Resource Server 호출용 access token이 아니다. ID Token은 client가 사용자 인증 결과를 확인하는 데 쓰고, access token은 Resource Server에 접근할 권한을 나타낸다.

UserInfo endpoint는 사용자의 profile claims를 가져오는 protected resource다. UserInfo 응답도 provider에서 온 속성일 뿐 내부 권한이 아니며, ID Token의 subject와 일치하는지 확인해야 계정 혼동을 줄일 수 있다.

이 문서의 핵심은 “provider가 사용자 정보를 줬다”가 아니라 “그 정보가 이 client를 위해 이 issuer가 발급한 검증된 인증 결과인가”다.

공격 시나리오

  • 공격자는 다른 client에 발급된 ID Token을 가져와 audience 검증이 약한 애플리케이션에 제시한다.
  • issuer 검증이 약하면 비슷한 provider나 테스트 issuer의 token을 받아들이게 만든다.
  • nonce 검증이 없으면 이전 인증 응답의 ID Token을 재사용한다.
  • email만 보고 내부 계정을 찾게 하면 같은 이메일 claim을 가진 다른 provider 계정을 연결하려 한다.
  • UserInfo subject가 ID Token subject와 다른데도 profile 정보를 신뢰하게 만든다.
  • expired token이나 서명 검증 실패 token을 캐시 때문에 통과시키려 한다.

취약한 요청/응답 예시

OAuth2User user = oauth2UserService.loadUser(userRequest);
String email = user.getAttribute("email");
Member member = memberRepository.findByEmail(email)
    .orElseGet(() -> memberRepository.save(Member.social(email)));
HTTP/1.1 302 Found
Location: /me
Set-Cookie: SESSION=created

문제는 provider, issuer, subject, email_verified, ID Token 검증 결과 없이 email만 내부 계정 키로 사용한다는 점이다.

주제별 핵심 판단

  • ID Token은 iss, sub, aud, exp, iat, nonce, signature를 검증해야 한다.
  • iss는 token을 발급한 issuer가 기대한 provider인지 확인한다.
  • aud는 token이 우리 client를 대상으로 발급되었는지 확인한다.
  • nonce는 인증 요청과 ID Token을 묶어 replay를 줄인다.
  • sub는 provider 내에서 사용자를 식별하는 안정 claim이다.
  • UserInfo의 sub는 ID Token의 sub와 일치해야 한다.
  • email과 profile은 속성일 뿐 내부 권한 또는 내부 계정 소유 증명이 아니다.

개선된 요청/응답 예시

public record ExternalIdentity(
    String provider,
    String issuer,
    String subject,
    String email,
    boolean emailVerified
) {
    public String stableKey() {
        return provider + ":" + subject;
    }
}
ExternalIdentity identity = externalIdentityFrom(oidcUser);
Member member = socialAccountService.loginOrRequireLink(identity);

내부 계정 연결은 provider + subject를 기본 키로 삼고 이메일은 보조 속성으로 저장한다. 기존 이메일 계정과 자동 병합하지 않는다.

방어 설계

  • OIDC provider 설정은 issuer URI 기준으로 등록한다.
  • ID Token signature와 key rotation을 framework가 검증하는지 확인한다.
  • issuer, audience, expiry, issued at, nonce 검증 실패를 로그인 실패로 처리한다.
  • UserInfo를 호출한다면 ID Token subject와 UserInfo subject 일치를 확인한다.
  • provider별 subject를 내부 외부 계정 테이블에 unique key로 저장한다.
  • email은 verified 여부와 함께 저장하고 내부 식별자 단독 기준으로 쓰지 않는다.
  • provider profile 변경은 내부 권한 변경으로 자동 반영하지 않는다.
  • claim mapping은 provider별 차이를 명시적으로 다룬다.

Spring/HTTP 예시

@Service
public class SocialOidcUserService extends OidcUserService {
 
    @Override
    public OidcUser loadUser(OidcUserRequest userRequest) {
        OidcUser oidcUser = super.loadUser(userRequest);
        OidcIdToken idToken = oidcUser.getIdToken();
 
        ExternalIdentity identity = new ExternalIdentity(
            userRequest.getClientRegistration().getRegistrationId(),
            idToken.getIssuer().toString(),
            idToken.getSubject(),
            oidcUser.getEmail(),
            Boolean.TRUE.equals(oidcUser.getEmailVerified())
        );
 
        socialAccountAudit.recordLoginAttempt(identity);
        return new DomainOidcUser(oidcUser, identity);
    }
}
@Bean
SecurityFilterChain security(HttpSecurity http, SocialOidcUserService oidcUserService) throws Exception {
    return http
        .oauth2Login(oauth -> oauth
            .userInfoEndpoint(userInfo -> userInfo
                .oidcUserService(oidcUserService)))
        .build();
}

Spring Security는 OIDC client 흐름의 표준 검증을 제공하지만, 내부 계정 연결 정책과 claim mapping은 애플리케이션이 명확히 정의해야 한다.

운영 로그와 감사 지점

level=INFO event=oidc_login_succeeded result=allowed provider=google issuer=https://accounts.google.com subjectHash=812f emailHash=0df1 emailVerified=true memberId=1004 traceId=2bb49c71
level=WARN event=oidc_claim_validation_failed result=blocked provider=google reason=audience_mismatch issuer=https://accounts.google.com subjectHash=812f traceId=2bb49c71
  • ID Token 원문은 로그에 남기지 않는다.
  • subject와 email은 원문보다 hash 또는 masked value를 남긴다.
  • claim 검증 실패 reason을 issuer, audience, expiry, nonce, signature로 구분한다.
  • UserInfo subject 불일치는 단순 profile 오류가 아니라 보안 이벤트로 다룬다.
  • provider claim mapping 변경은 배포 이벤트와 연결해 추적한다.

실전 판단 기준

  • ID Token을 Resource Server API 호출용 bearer token으로 보내면 목적이 틀렸다.
  • access token만 받아 UserInfo를 호출하고 email만 믿는 로그인은 provider별 위험을 따져야 한다.
  • email_verified가 false이거나 없는 provider는 기존 이메일 계정과 자동 연결하면 안 된다.
  • issuer와 audience 검증이 꺼져 있으면 token substitution에 취약하다.
  • nonce 검증이 없으면 오래된 인증 응답 재사용을 탐지하기 어렵다.
  • provider subject를 저장하지 않으면 나중에 계정 연결 문제를 복원하기 어렵다.

테스트 포인트

  • 다른 client_id 대상으로 발급된 ID Token이 거부되는지 확인한다.
  • 다른 issuer의 ID Token이 거부되는지 확인한다.
  • expired token과 잘못된 signature token이 거부되는지 확인한다.
  • nonce 불일치 또는 누락이 거부되는지 확인한다.
  • UserInfo subject가 ID Token subject와 다를 때 거부되는지 확인한다.
  • 같은 이메일을 가진 다른 provider 계정이 기존 내부 계정에 자동 연결되지 않는지 확인한다.

위험 신호!

  • 내부 사용자 식별 키가 email 하나뿐이다.
  • provider subject를 저장하지 않는다.
  • ID Token 검증 실패가 일반 로그인 실패와 구분되지 않는다.
  • UserInfo 응답을 내부 권한이나 role로 바로 mapping한다.
  • email_verified를 보지 않고 기존 이메일 계정과 자동 연결한다.
  • ID Token 원문이 debug log에 남는다.

확인 질문

확인 질문

  • ID Token은 무엇을 증명하는가?
    • 특정 issuer가 특정 client를 대상으로 사용자를 인증했다는 claims를 담은 token이다.
  • UserInfo 응답을 받을 때 중요한 subject 검증은 무엇인가?
    • UserInfo의 sub가 ID Token의 sub와 일치해야 한다.
  • 내부 계정 연결의 안정 키는 무엇이어야 하는가?
    • provider와 provider subject의 조합이다. 이메일은 보조 속성으로 다룬다.

참고 문서