이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
state, PKCE, redirect URI 검증은 각각 어떤 공격을 줄이는가?- prefix match나 wildcard redirect URI가 왜 위험한가?
- Spring OAuth2 Login에서 authorization request 저장과 callback 검증을 어떻게 점검해야 하는가?
개요
OAuth2 redirect 기반 흐름은 브라우저가 여러 도메인을 오가므로 callback을 신뢰하기 전에 “내가 시작한 flow의 응답인가”를 확인해야 한다. 이때 state, PKCE, redirect URI 검증이 서로 다른 역할을 맡는다.
state는 로그인 시작 요청과 callback을 묶어 CSRF와 flow 혼동을 줄인다. PKCE는 authorization code를 훔친 공격자가 token endpoint에서 code를 교환하기 어렵게 만든다. redirect URI 검증은 code가 공격자 endpoint로 전달되는 일을 막는다.
셋은 서로 대체하지 않는다. PKCE가 있어도 잘못된 redirect URI는 code 유출을 만들 수 있고, state가 없어도 사용자의 브라우저가 공격자가 시작한 flow를 완료하는 문제가 생긴다.
이 문서의 핵심은 “callback parameter를 검증한다”가 아니라 “각 parameter가 어떤 flow와 저장 상태에 묶여 있는지 확인한다”다.
공격 시나리오
- 공격자는 redirect URI 등록이 느슨한 client를 찾아
https://app.example.com.evil.test/callback같은 URI를 시도한다. - prefix match가 허용되면 정상 도메인처럼 보이는 하위 path나 open redirect endpoint를 이용해 code를 빼낸다.
- state가 없거나 고정값이면 피해자 브라우저에 공격자가 시작한 authorization response를 주입한다.
- PKCE가 없으면 탈취한 code를 token endpoint에서 바로 교환한다.
- code_verifier가 로그나 client storage에 오래 남으면 PKCE의 보호 효과가 약해진다.
- 여러 provider를 운영할 때 registrationId와 issuer를 묶어 확인하지 않으면 mix-up 공격에 노출될 수 있다.
취약한 요청/응답 예시
GET /oauth2/authorization/google?redirect_uri=https://app.example.com.evil.test/callback HTTP/1.1
Host: app.example.com
Cookie: SESSION=victim-sessionHTTP/1.1 302 Found
Location: https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=client-123&redirect_uri=https%3A%2F%2Fapp.example.com.evil.test%2Fcallback&state=fixedGET /login/oauth2/code/google?code=stolen-code&state=fixed HTTP/1.1
Host: app.example.com문제는 redirect URI가 요청값에 의해 흔들리고, state가 session과 강하게 묶이지 않으며, PKCE가 보이지 않는다는 점이다.
주제별 핵심 판단
- redirect URI는 provider에 등록된 정확한 URI와 매칭되어야 한다.
- wildcard, prefix match, open redirect endpoint를 callback URI로 쓰면 code 유출 위험이 커진다.
- state는 충분한 entropy를 가진 일회성 값이어야 하며 session이나 authorization request 저장소에 묶여야 한다.
- PKCE는
code_verifier와code_challenge를 사용하며 가능한S256을 사용한다. - code_verifier는 token 교환 전까지 짧게 보관하고 교환 후 제거한다.
- callback 검증 실패는 로그인 실패가 아니라 보안 이벤트다.
- 여러 Authorization Server를 쓴다면 issuer와 registrationId를 함께 확인한다.
개선된 요청/응답 예시
GET /oauth2/authorization/google HTTP/1.1
Host: app.example.com
Cookie: SESSION=victim-sessionHTTP/1.1 302 Found
Location: https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=client-123&redirect_uri=https%3A%2F%2Fapp.example.com%2Flogin%2Foauth2%2Fcode%2Fgoogle&state=8yZL...&code_challenge=9vQm...&code_challenge_method=S256POST /token HTTP/1.1
Host: accounts.google.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=...&redirect_uri=https%3A%2F%2Fapp.example.com%2Flogin%2Foauth2%2Fcode%2Fgoogle&code_verifier=original-random-verifierredirect URI는 서버 설정에서 정해지고, state와 code_verifier는 flow 시작 시 생성되어 callback/token 교환 시점에 검증된다.
방어 설계
- provider 콘솔과 애플리케이션 설정의 redirect URI를 정확히 맞춘다.
- OAuth 시작 endpoint에서 외부 입력으로 redirect URI를 직접 받지 않는다.
- 로그인 완료 후 이동할 내부 경로는 별도 allowlist parameter로 다루고 redirect URI와 섞지 않는다.
- state와 code_verifier를 session 또는 server-side authorization request repository에 저장한다.
- callback 처리 후 state와 code_verifier를 제거한다.
- provider별 registrationId, issuer, redirect URI를 한 묶음으로 다룬다.
- open redirect 취약점이 있는 내부 path를 OAuth redirect URI로 등록하지 않는다.
- 실패 reason을 구분해 탐지와 디버깅이 가능하게 한다.
Spring/HTTP 예시
@Bean
SecurityFilterChain oauthSecurity(HttpSecurity http) throws Exception {
return http
.oauth2Login(oauth -> oauth
.authorizationEndpoint(endpoint -> endpoint
.baseUri("/oauth2/authorization")
.authorizationRequestRepository(authorizationRequestRepository()))
.redirectionEndpoint(endpoint -> endpoint
.baseUri("/login/oauth2/code/*")))
.build();
}@Bean
AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository() {
return new HttpSessionOAuth2AuthorizationRequestRepository();
}Spring Security의 기본 흐름은 authorization request를 저장해 callback의 state와 대조한다. 운영 설계에서는 session fixation, distributed session, reverse proxy path rewrite가 이 저장과 검증을 깨지 않는지 확인한다.
운영 로그와 감사 지점
level=WARN event=oauth_callback_rejected result=blocked provider=google reason=redirect_uri_mismatch expected=https://app.example.com/login/oauth2/code/google actual=https://app.example.com.evil.test/callback traceId=0ec71a8dlevel=WARN event=oauth_callback_rejected result=blocked provider=google reason=state_mismatch sessionHash=be2a stateHash=91cd traceId=0ec71a8d- state와 code_verifier 원문은 남기지 말고 hash만 남긴다.
- redirect URI mismatch는 설정 오류와 공격 시도를 구분할 수 있게 expected/actual host를 남긴다.
- PKCE 실패는 token endpoint 오류로만 묻히지 않게 별도 event로 기록한다.
- callback 실패가 provider별로 급증하면 provider 설정 변경이나 공격을 의심한다.
- 로그인 후 내부 redirect target은 별도 로그 필드로 남겨 open redirect 탐지에 활용한다.
실전 판단 기준
- redirect URI가
startsWith로 검증되면 위험하다. - provider 콘솔에 wildcard callback이 등록되어 있으면 공격 표면이 넓다.
- state가 고정값이거나 세션에 묶이지 않으면 CSRF 방어가 약하다.
- PKCE를 쓰더라도 code_verifier가 브라우저 로그나 localStorage에 오래 남으면 위험하다.
- reverse proxy가 scheme/host를 바꾸면 provider에 등록된 redirect URI와 실제 URI가 달라질 수 있다.
- 로그인 후 이동 URL과 OAuth redirect URI를 같은 parameter로 다루면 open redirect와 섞인다.
테스트 포인트
- state 누락, state 불일치, 오래된 state 재사용이 모두 거부되는지 확인한다.
- redirect URI를 비슷한 도메인, 하위 path, URL encoded 값으로 바꿨을 때 거부되는지 확인한다.
- code_verifier가 없거나 틀렸을 때 token 교환이 실패하는지 확인한다.
- callback code를 두 번 재사용했을 때 실패 로그가 남는지 확인한다.
- 여러 provider 사이에서 registrationId와 issuer가 섞인 callback이 거부되는지 확인한다.
- 로그인 후 이동 target이 외부 URL이면 거부되는지 확인한다.
위험 신호!
- redirect URI를 prefix match, wildcard, contains로 허용한다.
- state가 없거나 항상 같은 값이다.
- PKCE method가
plain이고 보안 이유가 설명되어 있지 않다. - code_verifier가 로그, 쿠키, localStorage에 평문으로 오래 남는다.
- OAuth callback endpoint가 open redirect endpoint와 연결되어 있다.
- provider별 설정이 하나의 공통 callback에서 issuer 확인 없이 처리된다.
확인 질문
확인 질문
- state는 무엇을 막는가?
- 로그인 시작 요청과 callback을 연결해 CSRF와 flow 혼동을 줄인다.
- PKCE는 무엇을 막는가?
- authorization code가 탈취되어도 원래 flow가 가진 code_verifier 없이는 token 교환을 어렵게 만든다.
- redirect URI 검증은 왜 정확해야 하는가?
- code가 공격자가 제어하는 endpoint로 전달되면 PKCE와 state가 있어도 추가 공격 표면이 생기기 때문이다.