Published on

Spring Security OAuth2 로그인 401·state 불일치 해결

Authors

서드파티 OAuth2 로그인은 개발 환경에서는 잘 되다가도, 운영에 올리면 갑자기 401 Unauthorized 또는 state 불일치로 콜백이 실패하는 일이 흔합니다. 특히 Spring Security의 OAuth2 로그인은 state 값을 세션 또는 쿠키 기반 저장소에 보관했다가, 콜백 요청에서 동일한 state가 돌아와야 인증을 진행합니다. 이 흐름 중 하나라도 어긋나면 invalid_state_parameter 류의 에러가 나거나, 결국 인증이 성립하지 않아 401로 떨어집니다.

이 글에서는 다음을 목표로 합니다.

  • 401과 state 불일치를 “어디서 끊겼는지” 관측하는 방법
  • 프록시, HTTPS, SameSite, 도메인, 세션 저장소 문제를 빠르게 좁히는 체크리스트
  • Spring Boot 3.x, Spring Security 6 기준의 설정 예시와 코드

운영이 쿠버네티스/EKS라면 토큰 교환 단계에서 invalid_grant가 나는 케이스도 자주 이어지는데, 그건 별도의 원인(시간 오차, 리다이렉트 URI 불일치 등)이 많습니다. 필요하면 EKS Pod에서 OAuth2 400 invalid_grant 해결 가이드도 함께 보세요.

OAuth2 로그인에서 state가 왜 중요한가

OAuth2 Authorization Code 플로우에서 state는 CSRF 방어용 값입니다.

  1. 클라이언트가 인가 요청을 보낼 때 state를 생성
  2. 서버는 이 state를 저장(보통 세션)
  3. 인증 서버가 콜백으로 codestate를 돌려줌
  4. 서버는 “저장된 state”와 “돌아온 state”가 일치해야만 다음 단계(토큰 교환)로 진행

Spring Security에서는 이 저장소 역할을 AuthorizationRequestRepository가 담당합니다. 기본 구현은 세션 기반(HttpSessionOAuth2AuthorizationRequestRepository)이므로, 콜백 요청에서 같은 세션을 못 찾으면 state 불일치가 발생할 수 있습니다.

증상별로 원인 분류하기

1) 콜백에서 state 불일치

대표 원인

  • 세션이 유지되지 않음(쿠키가 안 오거나, 다른 도메인으로 이동)
  • 로드밸런서 뒤에서 세션 스티키가 없고 서버가 바뀜(세션이 인메모리일 때)
  • SameSite 정책 때문에 OAuth2 리다이렉트 콜백에서 쿠키가 누락
  • 프록시 뒤에서 X-Forwarded-Proto 처리가 안 되어 리다이렉트 URL/쿠키 정책이 꼬임

2) 로그인 완료 후 API 호출이 401

대표 원인

  • 로그인은 됐는데 세션이 안 붙어서 다음 요청에 인증 정보가 없음
  • SPA/프론트가 다른 도메인이고 CORS, 쿠키 옵션(SameSite, Secure, Domain)이 맞지 않음
  • 세션 기반인데 프론트가 fetchcredentials를 안 붙임
  • 리소스 서버(JWT)로 구성했는데 토큰 전달이 누락되었거나, Bearer 헤더가 제거됨(프록시/게이트웨이)

먼저 로그로 “끊긴 지점”을 잡기

Spring Security 로그 레벨을 올리면 state 검증 실패, 세션 로딩 실패, 리다이렉트 생성 등을 빠르게 확인할 수 있습니다.

logging:
  level:
    org.springframework.security: DEBUG
    org.springframework.security.oauth2: DEBUG

운영에서 DEBUG가 부담이면, 재현되는 짧은 시간만 올리거나 특정 패키지만 제한하세요.

추가로, 요청마다 세션 쿠키가 실제로 오고 있는지 확인하려면 애플리케이션 앞단(예: Nginx/ALB) 또는 앱에서 쿠키/헤더를 덤프하는 임시 로깅도 도움이 됩니다.

가장 흔한 원인 1: SameSite 쿠키 정책

OAuth2 리다이렉트는 “외부 도메인에서 우리 도메인으로 돌아오는 크로스 사이트 네비게이션”입니다. 브라우저의 SameSite 정책에 따라 세션 쿠키가 콜백 요청에 포함되지 않을 수 있습니다.

  • SameSite=Lax: 일반적인 top-level GET 네비게이션에는 포함되지만, 상황에 따라 누락되는 케이스가 있습니다(특히 중간에 프론트 라우팅, POST 콜백, 특이한 리다이렉트 체인).
  • SameSite=None: 크로스 사이트에도 보내지만 반드시 Secure가 필요합니다(HTTPS).

Spring Boot 3.x에서는 다음처럼 설정할 수 있습니다.

server:
  servlet:
    session:
      cookie:
        same-site: none
        secure: true

주의할 점

  • same-site: none을 쓰면 secure: true가 사실상 필수입니다.
  • 로컬 개발에서 HTTP로 테스트하면 쿠키가 저장되지 않아 더 혼란스러울 수 있습니다. 로컬도 HTTPS(예: mkcert)로 맞추거나, 개발 프로필에서만 다르게 설정하세요.

가장 흔한 원인 2: 프록시 뒤 HTTPS 인식 실패

ALB/Nginx/Ingress 뒤에서 앱이 HTTP로 인식하면 다음 문제가 연쇄적으로 발생합니다.

  • 리다이렉트 URI 생성이 HTTP로 나감
  • Secure 쿠키를 쓰는 경우 쿠키가 저장/전달되지 않음
  • 결과적으로 콜백에서 세션을 못 찾아 state 불일치

해결의 핵심은 Forwarded 헤더를 Spring이 올바르게 해석하도록 하는 것입니다.

server:
  forward-headers-strategy: framework

또는(환경에 따라) native가 필요할 수 있습니다. 인그레스/프록시가 어떤 헤더를 넣는지(X-Forwarded-Proto, X-Forwarded-Host) 먼저 확인하세요.

Nginx를 쓴다면 보통 아래 헤더들이 필요합니다.

proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host  $host;
proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;

가장 흔한 원인 3: 세션 저장소가 인메모리인데 서버가 바뀜

Spring Security 기본 AuthorizationRequestRepository는 세션에 state를 저장합니다. 그런데 운영이 다음과 같다면 문제가 됩니다.

  • 여러 인스턴스(파드)로 스케일 아웃
  • 세션이 인메모리(기본)
  • 스티키 세션이 없음

인가 요청을 A 파드가 처리해 세션에 state를 저장했는데, 콜백이 B 파드로 들어오면 B는 해당 세션을 모르므로 state 불일치가 납니다.

해결 옵션

  1. 스티키 세션을 켠다(권장도는 환경에 따라 다름)
  2. Spring Session + Redis로 세션을 공유한다
  3. 세션 대신 쿠키 기반 저장소로 OAuth2AuthorizationRequest를 저장한다

운영 안정성 관점에서는 2번(세션 외부화)이 가장 일반적입니다.

해결책 A: Spring Session Redis로 state 저장 안정화

의존성(Gradle 예시)

dependencies {
  implementation "org.springframework.session:spring-session-data-redis"
  implementation "org.springframework.boot:spring-boot-starter-data-redis"
}

설정 예시

spring:
  session:
    store-type: redis
  data:
    redis:
      host: localhost
      port: 6379

이렇게 하면 state를 포함한 세션이 Redis로 공유되어, 콜백이 어느 인스턴스로 들어와도 일관되게 검증할 수 있습니다.

해결책 B: AuthorizationRequest를 쿠키에 저장하기

세션을 쓰기 어렵거나, 완전 무상태에 가깝게 가고 싶다면 AuthorizationRequestRepository를 쿠키 기반으로 바꿀 수 있습니다.

핵심은 OAuth2AuthorizationRequest를 직렬화해 쿠키로 저장하고, 콜백에서 다시 꺼내는 것입니다. 아래 코드는 “구조 예시”이며, 운영에서는 암호화/서명, 쿠키 크기 제한, 만료 처리 등을 반드시 고려하세요.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

  @Bean
  SecurityFilterChain filterChain(HttpSecurity http,
      AuthorizationRequestRepository<OAuth2AuthorizationRequest> authReqRepo) throws Exception {

    http
      .authorizeHttpRequests(auth -> auth
        .requestMatchers("/", "/login/**", "/oauth2/**").permitAll()
        .anyRequest().authenticated()
      )
      .oauth2Login(oauth2 -> oauth2
        .authorizationEndpoint(authorization -> authorization
          .authorizationRequestRepository(authReqRepo)
        )
      );

    return http.build();
  }

  @Bean
  AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository() {
    return new CookieOAuth2AuthorizationRequestRepository();
  }
}

쿠키 저장소 구현은 팀/보안 요구사항에 따라 달라질 수 있어, 여기서는 인터페이스와 연결 지점만 보여줬습니다.

401을 만드는 프론트-백엔드 쿠키/CORS 조합 점검

로그인은 성공했는데 이후 API가 401이면, 대부분 “세션 쿠키가 API 요청에 안 붙는 문제”입니다.

프론트가 다른 도메인일 때 필수 체크

  • 백엔드 CORS에서 allowCredentials(true)
  • 프론트 요청에서 credentials: "include"
  • 쿠키가 SameSite=None + Secure
  • Access-Control-Allow-Origin*이면 credentials와 함께 쓸 수 없음(명시적 오리진 필요)

Spring Security 6에서 CORS 설정 예시

@Bean
CorsConfigurationSource corsConfigurationSource() {
  CorsConfiguration config = new CorsConfiguration();
  config.setAllowedOrigins(List.of("https://app.example.com"));
  config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
  config.setAllowedHeaders(List.of("*"));
  config.setAllowCredentials(true);

  UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
  source.registerCorsConfiguration("/**", config);
  return source;
}

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
  http
    .cors(cors -> cors.configurationSource(corsConfigurationSource()))
    .csrf(csrf -> csrf.ignoringRequestMatchers("/oauth2/**"));
  return http.build();
}

프론트 fetch 예시

fetch("https://api.example.com/me", {
  method: "GET",
  credentials: "include"
});

리다이렉트 URI 불일치가 state 문제처럼 보이는 경우

인가 요청 단계는 통과했는데 콜백에서 401 또는 에러 페이지로 떨어질 때, 실제 원인이 리다이렉트 URI mismatch인 경우도 있습니다. 다만 이 경우는 보통 인증 서버 쪽에서 먼저 에러를 내고, 애플리케이션 콜백까지 오지 않기도 합니다.

점검 포인트

  • spring.security.oauth2.client.registration.*.redirect-uri
  • 인증 서버 콘솔에 등록된 리다이렉트 URI
  • 프록시가 호스트/스킴을 바꿔치기하는지 여부

동적 템플릿을 쓴다면 아래처럼 {baseUrl} 기반을 사용하되, 프록시 헤더 처리가 선행되어야 합니다.

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"

위 설정에서 중괄호 템플릿이 실제로 어떤 URL로 해석되는지 로그로 확인해 두면, 운영에서 삽질을 크게 줄일 수 있습니다.

체크리스트: 10분 내 원인 좁히기

  1. 콜백 요청에 세션 쿠키가 포함되는가
    • 브라우저 DevTools Network에서 Request Headers의 Cookie 확인
  2. 콜백이 같은 도메인/서브도메인으로 들어오는가
    • www 유무, api 서브도메인 분리 여부
  3. SameSite=NoneSecure=true가 필요한 상황인가
  4. 앱이 HTTPS로 인식하는가
    • server.forward-headers-strategy 및 프록시 헤더
  5. 스케일 아웃 환경에서 세션이 공유되는가
    • 인메모리 세션이면 Spring Session Redis 또는 스티키 세션 고려
  6. 프론트-백엔드 분리 시 CORS와 credentials 설정이 맞는가

이런 류의 문제는 “원인이 하나”가 아니라, 쿠키 정책 변경과 프록시 설정 누락이 겹쳐서 터지는 경우가 많습니다. 이미지 최적화에서도 비슷하게 프록시/헤더/도메인 조합이 문제를 만들곤 하는데, 접근 방식(요청 흐름을 쪼개서 관측하고 가설을 제거)이 유사합니다. 필요하면 Next.js 이미지 최적화 실패? remotePatterns·403 해결도 참고가 됩니다.

운영에서 추천하는 안정 조합

  • 다중 인스턴스면 Spring Session Redis로 세션 외부화
  • SameSite=None + Secure=true를 기본으로 두고, 로컬만 예외 처리
  • 프록시/인그레스 환경이면 server.forward-headers-strategy: framework 적용
  • 프론트 분리면 CORS credentials를 명확히 구성

마지막으로, state 불일치와 401을 잡았는데도 토큰 교환 단계에서 실패한다면(특히 쿠버네티스에서) 시간 동기화/리다이렉트 URI/클라이언트 시크릿 주입 문제로 이어지는 경우가 많습니다. 그때는 앞서 언급한 EKS Pod에서 OAuth2 400 invalid_grant 해결 가이드를 같이 점검하면 전체 플로우를 빠르게 안정화할 수 있습니다.