- Published on
Spring Security OAuth2 리다이렉트 루프 끊는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에선 302만 반복되고 브라우저는 ERR_TOO_MANY_REDIRECTS(혹은 "too many redirects")를 뱉는다. OAuth2 로그인 플로우에서는 이 현상이 특히 자주 나오는데, 이유는 단순하다. Spring Security가 "인증이 필요하다"고 판단해 로그인 엔드포인트로 보냈는데, 로그인 엔드포인트가 다시 "인증이 필요하다"고 판단하거나(보안 매칭/엔트리포인트), 혹은 콜백을 처리한 뒤에도 인증 상태가 유지되지 않아(세션/쿠키/도메인/secure) 다시 처음으로 돌아가기 때문이다.
이 글은 Spring Security OAuth2 Client(구글/깃허브/사내 IdP 등)에서 리다이렉트 루프가 생기는 전형적인 패턴을 재현 → 로그로 원인 특정 → 설정으로 차단 순서로 정리한다.
리다이렉트 루프가 생기는 대표 시나리오
OAuth2 Authorization Code 플로우(가장 흔한 웹 로그인)는 대략 아래 이동을 한다.
- 사용자가 보호 리소스 접근:
/또는/app - Spring Security: 인증 필요 →
/oauth2/authorization/{registrationId}로 302 - IdP로 302:
https://accounts.google.com/o/oauth2/v2/auth?...&redirect_uri=... - IdP가 콜백으로 302:
https://myapp.com/login/oauth2/code/google?code=...&state=... - 애플리케이션이 code 교환 후 인증 성공 → 원래 가려던 URL로 302
루프는 주로 4~5 단계에서 난다.
- 콜백 URL(
/login/oauth2/code/*)이 보호되어 다시 로그인으로 튕김 - 콜백 처리 후 인증이 성공했는데도 세션/쿠키가 브라우저에 저장되지 않음(Secure, SameSite, 도메인, 프록시)
- 프록시/로드밸런서 뒤에서 redirectUri(스킴/호스트)가 틀려 계속 실패 → 다시 시작
- 커스텀
AuthenticationEntryPoint/SuccessHandler가 잘못되어 항상 로그인으로 리다이렉트
프록시 환경에서 redirectUri가 꼬이는 케이스는 별도로 정리해둔 글이 있으니, Nginx/ALB 뒤에 있다면 먼저 같이 보는 게 빠르다: Proxy 뒤 Nginx에서 OAuth 리다이렉트 URI 불일치 해결
1) 먼저 “어디서 루프가 도는지”를 눈으로 확인하기
브라우저/터미널로 Location 체인 확인
가장 먼저 해야 할 것은 302 Location 체인을 보는 것이다.
# -I: 헤더만, -L: 리다이렉트 따라가기
curl -k -I -L https://myapp.com/ \
-c cookies.txt -b cookies.txt
여기서 확인할 포인트:
/oauth2/authorization/google→ IdP →/login/oauth2/code/google까지는 정상인가?/login/oauth2/code/google에서 다시/oauth2/authorization/google로 돌아가면 콜백이 보안에 걸리거나, 콜백 처리 후 세션이 유지되지 않는 것/login또는 커스텀 로그인 페이지로 계속 돌아가면 EntryPoint/permitAll/매칭 문제
Spring Security 디버그 로그 켜기
application.yml에 아래를 추가한다.
logging:
level:
org.springframework.security: TRACE
org.springframework.security.oauth2: TRACE
TRACE 로그에서 특히 유용한 메시지:
Request is to process authenticationRedirecting to ... /oauth2/authorization/...Did not match request to ...(필터/매칭)Saved request .../Redirecting to DefaultSavedRequest
이 로그로 어떤 요청이 어떤 필터에서 차단되었는지가 거의 드러난다.
2) 콜백 URL이 보호되어 발생하는 루프 (permitAll 누락)
Spring Security의 기본 OAuth2 로그인 구성은 보통 콜백 엔드포인트(/login/oauth2/code/*)를 내부적으로 처리하지만, 커스텀 securityMatcher/requestMatchers를 잘못 구성하면 콜백이 보호되어 무한 리다이렉트가 된다.
흔한 잘못된 설정
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**") // 또는 특정 matcher만 걸어둔 경우
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.oauth2Login(Customizer.withDefaults());
return http.build();
}
위처럼 matcher를 잘못 걸면, 콜백 경로가 이 체인에서 처리되지 않거나, 반대로 콜백이 인증 필요로 판정되어 다시 로그인으로 빠질 수 있다.
해결: 콜백/authorization 엔드포인트 명시적으로 허용
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
// OAuth2 로그인 시작/콜백은 반드시 열어둔다
.requestMatchers("/oauth2/authorization/**").permitAll()
.requestMatchers("/login/oauth2/code/**").permitAll()
// 정적 리소스/헬스체크도 필요시 허용
.requestMatchers("/actuator/health", "/css/**", "/js/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(Customizer.withDefaults());
return http.build();
}
만약 커스텀 콜백 경로를 쓰고 있다면(redirectionEndpoint().baseUri(...)) 그 경로도 동일하게 permitAll 해야 한다.
3) 인증은 성공했는데 “다시 비로그인”이 되는 루프 (세션/쿠키 문제)
로그를 보면 OAuth2LoginAuthenticationFilter가 성공하고 SecurityContext에 인증이 들어간 것처럼 보이는데, 다음 요청에서 다시 AnonymousAuthenticationToken으로 돌아가면 세션이 유지되지 않는 것이다.
이 경우 원인은 대부분 쿠키다.
체크리스트
JSESSIONID가 응답에 Set-Cookie로 내려오는가?- 브라우저가 그 쿠키를 저장하는가?
- 다음 요청에 쿠키가 다시 전송되는가?
특히 아래 조건에서 잘 터진다.
- HTTPS 뒤 프록시인데 애플리케이션이 HTTP로 인식 →
Secure쿠키를 안 붙이거나, 반대로 Secure 쿠키를 붙였는데 브라우저가 http로 판단해 저장 안 함 - SameSite 정책 때문에 IdP → 콜백 이동에서 쿠키가 안 붙음(특히 크로스사이트/서브도메인)
- 도메인 불일치(
app.example.com↔example.com) 또는 경로 문제
Spring Boot에서 Forwarded 헤더 처리(프록시 환경 핵심)
ALB/Nginx/Ingress 뒤에서는 외부는 HTTPS인데 내부는 HTTP인 경우가 흔하다. 이때 X-Forwarded-Proto, X-Forwarded-Host를 Spring이 신뢰하지 않으면 리다이렉트 URL과 쿠키 정책이 꼬인다.
Boot 3 기준 권장 설정:
server:
forward-headers-strategy: framework
그리고 프록시가 아래 헤더를 올바르게 넣는지 확인한다.
X-Forwarded-Proto: httpsX-Forwarded-Host: myapp.comX-Forwarded-Port: 443(필요 시)
SameSite/secure 쿠키 설정(필요한 경우만)
만약 콜백 과정에서 쿠키가 누락된다면 세션 쿠키 정책을 조정해야 한다. Boot에서 세션 쿠키는 다음처럼 잡을 수 있다.
server:
servlet:
session:
cookie:
secure: true
same-site: LAX
- 일반적인 OAuth2 로그인은 SameSite=Lax로도 충분한 경우가 많다.
- SPA에서 크로스사이트로 복잡하게 얽히면
None; Secure가 필요할 수 있다(반드시 HTTPS).
4) redirect_uri/issuer 불일치로 “실패→재시작” 루프
IdP에서 콜백을 돌려줬는데 서버가 이를 처리하다가 예외를 내고(또는 실패 핸들러가 로그인으로 보냄) 다시 /oauth2/authorization/...로 보내는 패턴이다.
대표 로그/증상:
Invalid redirect_uri(IdP)authorization_request_not_found(state 저장소/세션 문제)An expected CSRF token cannot be found류(구성에 따라)
프록시 뒤에서 앱이 http://internal:8080/login/oauth2/code/... 같은 redirectUri를 만들면 IdP 등록값과 달라져 실패한다. 이 경우는 대개 Forwarded 헤더 처리 + 프록시 설정으로 해결된다.
프록시 환경에서의 redirectUri 불일치 해결은 아래 글의 체크리스트가 그대로 적용된다: Proxy 뒤 Nginx에서 OAuth 리다이렉트 URI 불일치 해결
5) 커스텀 EntryPoint/SuccessHandler가 루프를 만드는 경우
다음과 같은 커스텀 로직이 있을 때 루프가 생긴다.
- 인증 실패/성공 시 무조건 특정 URL로 리다이렉트
- SavedRequest를 무시하고 다시 보호 리소스로 보내는데, 그 리소스가 또 인증을 요구
안전한 성공 핸들러 예시
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/oauth2/authorization/**", "/login/oauth2/code/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth -> oauth
.successHandler((request, response, authentication) -> {
// SavedRequest가 있으면 그리로, 없으면 홈으로
var saved = new org.springframework.security.web.savedrequest.HttpSessionRequestCache()
.getRequest(request, response);
String target = (saved != null) ? saved.getRedirectUrl() : "/";
response.sendRedirect(target);
})
);
return http.build();
}
그리고 실패 핸들러에서도 “다시 보호 리소스”로 보내는 실수를 피하고, 명확한 에러 페이지나 /login?error로 보내 원인을 노출시키는 편이 디버깅에 유리하다.
6) state(AuthorizationRequest) 유실로 무한 반복되는 케이스
OAuth2는 state로 요청 위조를 방지한다. Spring Security는 보통 HttpSessionOAuth2AuthorizationRequestRepository를 써서 세션에 authorization request를 저장한다.
세션이 없거나(무상태), 혹은 콜백 요청에서 세션 쿠키가 안 붙으면 아래 오류가 난다.
authorization_request_not_found
이때 애플리케이션이 실패 후 다시 /oauth2/authorization/...로 보내면 사용자는 “무한 로그인”처럼 보게 된다.
해결 방향
- 서버 사이드 세션을 유지한다(기본).
- 정말 무상태로 해야 한다면, authorization request를 쿠키 등에 저장하는 커스텀 Repository를 쓴다(보안/크기/암호화 고려 필요).
- 대부분은 프록시/쿠키 설정 문제가 원인이라 3)부터 먼저 해결하는 게 빠르다.
7) 실전 트러블슈팅 순서(30분 안에 끝내기)
curl -I -L로 302 체인을 캡처하고, 루프가 도는 두 URL을 적는다.org.springframework.security=TRACE로 올리고, 루프 구간의 요청이- 어떤 필터에서 차단되는지
- 인증 성공이 찍히는지
- 세션이 유지되는지 확인한다.
- 콜백/authorization 경로가
permitAll인지 확인한다. - 프록시 뒤라면
server.forward-headers-strategy=framework+X-Forwarded-*헤더를 점검한다. - 브라우저 개발자도구에서
JSESSIONIDSet-Cookie/전송 여부를 확인한다. - 그래도 안 되면
SuccessHandler/FailureHandler/EntryPoint커스텀을 제거하고 기본값으로 최소 재현 후 다시 붙인다.
부록: 최소 동작 예제(Spring Boot 3 + OAuth2 Login)
아래는 “루프를 만들 여지가 적은” 기본형이다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/public/**").permitAll()
.requestMatchers("/oauth2/authorization/**", "/login/oauth2/code/**").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, profile, email
server:
forward-headers-strategy: framework
마무리
Spring Security OAuth2의 리다이렉트 루프는 겉으로는 "브라우저 문제"처럼 보이지만, 실제로는 (1) 콜백/authorization 경로 보안 매칭, (2) 세션 쿠키 유지 실패, (3) 프록시 환경에서의 redirectUri/스킴 인식 오류, (4) 커스텀 핸들러의 잘못된 리다이렉트 네 가지 중 하나로 거의 수렴한다.
특히 프록시 뒤에서 발생하는 케이스가 가장 흔하니, X-Forwarded-*와 forward-headers-strategy부터 맞추고, 그 다음 permitAll/쿠키를 확인하면 대부분의 too many redirects는 깔끔하게 끊긴다.
추가로, OAuth2 로그인 이후 발급받은 토큰(JWT)을 다른 서비스에서 검증하는 단계까지 갔다면, 운영 중 401이 간헐적으로 튀는 문제도 자주 만나게 된다. 그 경우엔 JWKS 캐시/kid 회전 이슈를 다룬 글이 도움이 된다: Node.js JWT 검증 실패 - kid·JWKS 캐시로 401 잡기