- Published on
Spring Security OAuth2 401 루프 - state·redirect_uri
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서드파티 OAuth2 로그인 붙이고 나면 가장 당황스러운 증상 중 하나가 401이 끝없이 반복되는 “로그인 루프”입니다. 브라우저에서는 로그인 버튼을 눌러도 다시 로그인 화면으로 돌아오거나, 콜백을 탔다고 생각했는데 다시 인증 시작 엔드포인트로 리다이렉트되는 패턴이 보입니다.
이 루프의 핵심 원인은 대부분 둘 중 하나로 수렴합니다.
state검증 실패(또는 state를 보관하던 저장소 유실)redirect_uri불일치(등록값과 실제 값이 다름)
문제는 이 둘이 프록시/로드밸런서, HTTPS 종료, SameSite 쿠키 정책, 멀티 인스턴스 배포 같은 환경 요인과 결합하면서 “겉으로는 401 루프”로만 보인다는 점입니다. 이 글에서는 Spring Security OAuth2(Client) 기준으로 재현 포인트, 로그로 확인하는 방법, 그리고 실전 해결책을 정리합니다.
401 루프의 전형적인 흐름
다음 흐름을 떠올리면 디버깅이 빨라집니다.
- 사용자가
/oauth2/authorization/{registrationId}접근 - Spring Security가
state생성 후OAuth2AuthorizationRequest를 저장(기본은 세션) - Provider로 리다이렉트
- Provider가
code와state를 콜백(redirect_uri)으로 전달 - Spring Security가 저장된 요청과 콜백의
state를 비교 - 성공 시 토큰 교환 후 인증 완료
여기서 5번이 실패하거나, 4번에서 돌아온 콜백 URL이 Spring이 기대하는 것과 다르면 인증이 완료되지 않습니다. 인증이 완료되지 않으면 보호 자원 접근 시 다시 1번으로 유도되고, 결과적으로 “401 루프”처럼 보입니다.
증상별로 원인 가르는 체크리스트
A. 콜백에서 바로 401 또는 다시 로그인으로 튕김
statemismatch- 세션 쿠키가 콜백 요청에 실리지 않음(SameSite, Secure, 도메인)
- 멀티 인스턴스에서 세션 고정이 안 됨(스티키 세션 없음)
B. Provider에서 redirect_uri_mismatch 또는 400이 보이는데 앱에서는 401만 보임
- Provider 콘솔에 등록한
redirect_uri와 실제 콜백 URL이 다름 - 프록시가
http로 보이게 만들어 Spring이http기반 콜백을 생성 - 컨텍스트 패스, 포트, 서브도메인 차이
C. 로컬에서는 되는데 운영에서만 401 루프
- HTTPS 종료 지점(LB, Ingress) 뒤에서
X-Forwarded-*처리가 안 됨 - 운영 도메인에서 쿠키 도메인/경로가 달라 세션 유실
- 인프라 변경(예: Ingress 경로 리라이트)로 콜백 경로가 바뀜
배포/동기화 이슈가 원인일 때는 애플리케이션 설정이 실제로 반영됐는지부터 확인해야 합니다. GitOps 환경이라면 Argo CD Sync 실패 - OutOfSync·Degraded 해결법 같은 체크리스트도 함께 보면 좋습니다.
1) state 문제: “저장된 AuthorizationRequest가 없다”
Spring Security OAuth2 Client는 기본적으로 HttpSessionOAuth2AuthorizationRequestRepository를 사용합니다. 즉, 인증 시작 시점에 만든 OAuth2AuthorizationRequest를 세션에 저장하고, 콜백에서 세션을 통해 다시 꺼내 state를 검증합니다.
따라서 콜백 요청에 세션 쿠키가 없으면 아래와 같은 일이 벌어집니다.
- 콜백에서 저장된 요청을 못 찾음
state를 비교할 대상이 없음- 인증 실패로 처리
- 보호 자원 접근 시 다시 인증 시작으로 리다이렉트
대표 원인 1: SameSite 쿠키 정책
크로스 사이트 리다이렉트가 포함되는 OAuth2 플로우에서는 쿠키의 SameSite 설정이 매우 중요합니다.
SameSite=Strict: 거의 확실히 OAuth2에서 문제SameSite=Lax: GET 기반 top-level navigation에서는 동작할 때가 많지만, 환경에 따라 애매SameSite=None; Secure: 크로스 사이트 리다이렉트에 가장 안전(HTTPS 필수)
Spring Boot 3.x에서 쿠키 SameSite를 조정하는 예시입니다.
server:
servlet:
session:
cookie:
same-site: none
secure: true
주의할 점은 SameSite=None은 반드시 Secure=true(HTTPS)여야 브라우저가 쿠키를 수용한다는 것입니다. 로컬에서 HTTPS가 아니라면 환경별 프로파일로 분리하거나, 로컬은 lax로 두는 식으로 운영하세요.
대표 원인 2: 멀티 인스턴스에서 세션 유실
인증 시작 요청이 인스턴스 A에 갔다가, 콜백이 인스턴스 B로 들어오면 B는 세션을 모릅니다(스티키 세션이 없고 세션 공유도 없을 때). 이 경우도 state 검증이 실패합니다.
해결책은 다음 중 하나입니다.
- 로드밸런서에서 스티키 세션 활성화
- Spring Session + Redis 등으로 세션 클러스터링
- 세션이 아닌 쿠키 기반 저장소로
AuthorizationRequestRepository를 변경
쿠키 기반 저장소는 운영 정책에 따라 선택해야 합니다. 민감 정보가 들어가지 않도록 암호화/서명 전략이 필요할 수 있습니다.
로그로 확인하기
Spring Security 로그를 올리면 state 검증 실패 흔적이 더 잘 보입니다.
logging:
level:
org.springframework.security: TRACE
org.springframework.security.oauth2: TRACE
그리고 브라우저 DevTools에서 콜백 요청에 세션 쿠키(JSESSIONID 등)가 포함되는지 확인하세요.
2) redirect_uri 문제: “생성된 콜백 URL이 등록값과 다르다”
redirect_uri는 Provider가 “이 앱이 맞는지” 검증하는 가장 중요한 값입니다. 등록된 값과 단 한 글자라도 다르면 Provider는 에러를 내거나, 일부 환경에서는 애매한 실패 후 앱으로 돌아와서 다시 인증을 시도하는 루프를 만들기도 합니다.
Spring이 redirect URI를 만드는 규칙
Spring Security는 기본적으로 템플릿 "{baseUrl}/login/oauth2/code/{registrationId}" 형태로 콜백을 구성합니다. 여기서 {baseUrl}은 요청을 기반으로 계산되는데, 프록시 뒤에서는 이 계산이 자주 틀어집니다.
예를 들어 실제 외부는 HTTPS인데, 앱이 내부에서 HTTP로 요청을 받으면 {baseUrl}이 http://...로 계산될 수 있습니다. 그러면 Provider에 등록한 https://...와 불일치가 발생합니다.
해결 1: Forwarded 헤더 처리
Ingress/LB가 X-Forwarded-Proto, X-Forwarded-Host를 넣어주는 환경이라면 Spring이 이를 신뢰하도록 설정해야 합니다.
server:
forward-headers-strategy: framework
또는 인프라에서 RFC 7239 Forwarded 헤더를 쓰는 경우도 있습니다. 핵심은 “외부에서 보이는 스킴/호스트”를 Spring이 올바르게 복원하도록 만드는 것입니다.
추가로, Spring Security 설정에서 HTTPS 강제를 걸어 혼선을 줄일 수 있습니다.
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.requiresChannel(channel -> channel.anyRequest().requiresSecure())
.oauth2Login(Customizer.withDefaults());
return http.build();
}
해결 2: redirect-uri를 명시적으로 고정
환경이 복잡해서 baseUrl 계산을 믿기 어렵다면, redirect-uri를 명시적으로 지정하는 방법도 있습니다.
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope:
- openid
- email
- profile
redirect-uri: "https://app.example.com/login/oauth2/code/{registrationId}"
이 방식은 단순하지만, 환경별 도메인이 다르면 프로파일 분리가 필요합니다.
Provider 콘솔에서 확인할 것
- 등록된 redirect URI에
http와https를 혼용해 넣지 않았는지 - 트레일링 슬래시(
/) 유무 - 포트 포함 여부(예:
:443을 넣었는지) - 서브도메인 차이(예:
www유무) - 경로 리라이트로 콜백 경로가 바뀌지 않았는지
3) state와 redirect_uri가 “둘 다” 문제인 경우
운영에서 흔한 케이스는 다음 조합입니다.
- 프록시 뒤에서 baseUrl이
http로 계산됨->Provider는redirect_uri_mismatch - 동시에 세션 쿠키가
Secure가 아니라서 HTTPS에서 쿠키가 빠짐->state 저장소 유실
이때 앱 로그만 보면 인증 실패가 뭉뚱그려져 401 루프로 보입니다. 그래서 진단 순서는 아래가 효율적입니다.
- Provider 에러 페이지/로그에서
redirect_uri관련 메시지 확인 - 콜백 요청의 URL이 정확히 무엇인지 확인(스킴/호스트/경로)
- 콜백 요청에 세션 쿠키가 포함되는지 확인
- Spring Security TRACE 로그로 state 저장/복원 여부 확인
4) 재현 가능한 최소 설정 예시
아래는 Spring Boot 3.x + Spring Security OAuth2 Login의 최소 예시입니다.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/error").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(Customizer.withDefaults())
.logout(logout -> logout.logoutSuccessUrl("/"));
return http.build();
}
}
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope:
- openid
- email
- profile
server:
forward-headers-strategy: framework
servlet:
session:
cookie:
same-site: none
secure: true
logging:
level:
org.springframework.security: TRACE
이 조합은 “프록시 뒤 HTTPS 환경”에서 가장 흔한 함정(Forwarded 헤더 미처리, SameSite 문제)을 동시에 방어합니다.
5) 운영 점검 팁: 네트워크/프록시 관점
OAuth2 로그인 루프는 애플리케이션 버그처럼 보이지만, 실제로는 “요청이 외부에서 어떻게 보이느냐”가 결정합니다. 인그레스나 게이트웨이 설정 변경이 있었다면 다음을 함께 확인하세요.
- 외부에서 앱으로 들어오는 스킴이 HTTPS인지
X-Forwarded-Proto와X-Forwarded-Host가 기대대로 들어오는지- 콜백 경로가 리라이트되지 않는지
- HSTS나 리다이렉트 규칙이 중복 적용되지 않는지
프록시/라우팅 이슈는 다른 장애에서도 반복됩니다. 예를 들어 CI에서 OIDC 연동이 꼬여 403이 나는 케이스도 결국 “요청 주체/리다이렉트/신뢰 경계” 문제로 귀결되는 경우가 많습니다. 비슷한 관점의 트러블슈팅은 GitHub Actions OIDC로 AWS 배포 403 해결 가이드도 참고할 만합니다.
6) 결론: 401 루프를 끝내는 3단계
- 콜백 요청에서 세션 쿠키가 살아있는지 확인(
state저장소 관점) - 콜백 URL이 Provider에 등록된
redirect_uri와 완전히 동일한지 확인(스킴/호스트/경로/슬래시) - 프록시 뒤라면
forward-headers-strategy및 쿠키SameSite=None; Secure를 우선 적용
마지막으로, 프론트가 Next.js이고 백엔드가 Spring인 조합에서는 도메인 분리(app.example.com과 api.example.com)로 인해 쿠키 스코프가 어긋나는 경우도 자주 봅니다. 이때는 “어떤 도메인에 어떤 쿠키가 저장되고, 어떤 요청에 실리는지”를 네트워크 탭에서 먼저 눈으로 확인하는 것이 가장 빠른 해결책입니다.