Published on

Spring Security OAuth state 불일치로 401 날 때 해결법

Authors

서드파티 OAuth2 로그인(구글/깃허브/카카오 등)을 Spring Security로 붙였는데, 콜백에서 갑자기 401이 떨어지거나 InvalidStateParameterException, authorization_request_not_found, state parameter mismatch 류의 에러가 간헐적으로 발생하는 경우가 있습니다.

이 문제의 핵심은 거의 항상 같습니다. 인가 요청(Authorization Request)을 저장해둔 곳(대개 세션/쿠키)과 콜백 요청이 만나는 지점이 어긋나서, Spring Security가 “내가 발급한 state가 아니다”라고 판단하는 것입니다.

이 글에서는 Spring Security OAuth2에서 state가 어떻게 저장·검증되는지부터, 실무에서 자주 터지는 원인(세션 유실, SameSite 쿠키, 도메인/프록시, LB 스티키 세션, 리다이렉트 URI 불일치)을 체크리스트로 정리하고, 가장 안전한 해결 패턴(쿠키 기반 AuthorizationRequestRepository, 프록시 헤더 처리, 세션 정책 정리)을 코드로 제시합니다.

관련해서 리다이렉트가 무한 반복되는 케이스도 자주 같이 나타나므로, 필요하면 Spring Security OAuth2 리다이렉트 루프 끊는 법도 함께 참고하면 좋습니다.

1) OAuth2 state가 왜 중요한가

OAuth2 Authorization Code Flow에서 state는 CSRF 방어용 난수 토큰입니다.

  1. 클라이언트가 /oauth2/authorization/{registrationId}로 진입
  2. Spring Security가 Authorization Request를 만들고, 여기에 state를 포함
  3. 이 Authorization Request를 어딘가에 저장(기본은 HTTP 세션)
  4. 사용자가 Provider에서 인증 후 redirect_uri로 돌아옴 (/login/oauth2/code/{registrationId})
  5. Spring Security가 저장해둔 Authorization Request를 꺼내서 콜백의 state와 비교

여기서 3~5 사이가 깨지면 state mismatch가 발생합니다. 즉,

  • 콜백 요청이 다른 세션으로 들어오거나
  • 세션 쿠키가 브라우저에 저장되지 않거나
  • 저장소(세션/쿠키)가 중간에 사라지거나
  • 요청이 다른 호스트/스킴/도메인으로 바뀌어 쿠키가 안 붙거나

하면 검증이 실패합니다.

2) 증상과 로그 포인트

환경마다 메시지는 조금씩 다르지만, 보통 아래 중 하나로 나타납니다.

  • 401 Unauthorized (콜백에서 바로)
  • InvalidStateParameterException
  • OAuth2AuthenticationException: [authorization_request_not_found]
  • DefaultAuthorizationCodeTokenResponseClient 단계에서 실패

Spring Security 디버그 로그 켜기

원인 파악을 위해 일단 시큐리티 로그를 올려두면 좋습니다.

# application.properties
logging.level.org.springframework.security=DEBUG
logging.level.org.springframework.web=DEBUG

콜백 시점에 HttpSessionOAuth2AuthorizationRequestRepository가 “요청을 못 찾았다”거나, state 비교에서 mismatch를 출력하는 경우가 많습니다.

3) 가장 흔한 원인 7가지 (실무 체크리스트)

(1) SameSite 쿠키 정책 때문에 세션 쿠키가 콜백에 안 붙음

최근 브라우저는 기본적으로 쿠키에 SameSite 정책을 적용합니다.

  • Provider → 우리 서비스로 리다이렉트는 크로스 사이트 네비게이션이 될 수 있음
  • 세션 쿠키가 SameSite=Lax/Strict로 처리되면 콜백에 쿠키가 누락될 수 있음

특히 다음 조합에서 자주 터집니다.

  • 프론트 도메인과 백엔드 도메인이 다름
  • HTTPS/HTTP 혼재
  • 모바일 웹뷰/인앱 브라우저

(2) LB/Ingress 뒤에서 스티키 세션이 없고 서버가 여러 대

기본 저장소가 세션(인메모리) 인데, 인가 요청은 A 서버에 저장되고 콜백은 B 서버로 가면 못 찾습니다.

해결 방향은 둘 중 하나입니다.

  • 세션을 공유(예: Redis Spring Session)
  • 혹은 AuthorizationRequest 저장소를 세션이 아닌 쿠키로 변경

(3) 프록시/ALB 뒤에서 스킴(https) 인식이 틀어져 redirect_uri가 달라짐

백엔드는 http로 알고 redirect_uri를 http://...로 만들었는데, 실제 외부는 https라서 Provider에 등록된 redirect_uri와 달라지는 경우가 있습니다.

이 케이스는 state mismatch뿐 아니라 리다이렉트 루프/인가 실패를 동반합니다. 인그레스/ALB 환경이면 X-Forwarded-* 헤더 처리 설정이 중요합니다.

(4) 도메인/서브도메인 불일치로 쿠키 스코프가 안 맞음

예를 들어,

  • 인가 요청: api.example.com
  • 콜백: auth.example.com

처럼 호스트가 바뀌면, 세션 쿠키가 다른 호스트로 전송되지 않을 수 있습니다.

(5) SPA에서 백엔드 로그인 엔드포인트를 fetch/XHR로 호출

OAuth2 로그인 시작은 브라우저 리다이렉트로 진행되어야 안정적입니다.

  • fetch('/oauth2/authorization/google')로 호출하면
    • 리다이렉트 처리/쿠키 저장이 브라우저 정책에 따라 꼬일 수 있음

항상 window.location = '/oauth2/authorization/google'처럼 top-level navigation으로 시작하는 게 안전합니다.

(6) 콜백 경로가 보안 필터에서 막히거나, 커스텀 필터가 세션을 무효화

로그인 과정 중간에 session.invalidate()를 호출하거나, 특정 필터가 콜백 요청에서 세션을 새로 만들면 state가 증발합니다.

(7) Provider 설정의 redirect_uri가 여러 개이고 환경별로 뒤섞임

개발/스테이징/운영에서 redirect_uri를 다르게 등록하는데, 프론트가 잘못된 환경으로 보내거나, 백엔드가 잘못된 baseUrl을 계산하면 mismatch가 발생합니다.

4) 해결 전략 A: 쿠키 기반 AuthorizationRequestRepository로 전환

세션 기반 저장소(HttpSessionOAuth2AuthorizationRequestRepository)는 멀티 인스턴스/서버리스/스케일아웃 환경에서 취약합니다.

가장 실용적인 해결책은 Authorization Request를 암호화/서명된 쿠키에 저장하는 방식입니다.

아래는 (많이 쓰이는 패턴인) 쿠키 기반 OAuth2AuthorizationRequestRepository 예시입니다. 핵심은:

  • 인가 요청 시 AuthorizationRequest를 쿠키에 저장
  • 콜백 시 쿠키에서 읽어 검증
  • 완료 후 쿠키 삭제

쿠키 유틸

public final class CookieUtils {
    private CookieUtils() {}

    public static Optional<Cookie> getCookie(HttpServletRequest request, String name) {
        if (request.getCookies() == null) return Optional.empty();
        return Arrays.stream(request.getCookies())
                .filter(c -> c.getName().equals(name))
                .findFirst();
    }

    public static void addCookie(HttpServletResponse response, String name, String value, int maxAge, boolean secure) {
        Cookie cookie = new Cookie(name, value);
        cookie.setPath("/");
        cookie.setHttpOnly(true);
        cookie.setSecure(secure);
        cookie.setMaxAge(maxAge);
        // SameSite는 서블릿 Cookie API에 직접 설정이 어려워 헤더로 내려야 하는 경우가 많음
        response.addCookie(cookie);
    }

    public static void deleteCookie(HttpServletRequest request, HttpServletResponse response, String name) {
        Cookie cookie = new Cookie(name, "");
        cookie.setPath("/");
        cookie.setMaxAge(0);
        response.addCookie(cookie);
    }
}

쿠키 기반 AuthorizationRequestRepository

(직렬화는 Base64를 쓰되, 운영에서는 서명/암호화를 권장합니다. 최소한 변조 방지를 위해 MAC을 붙이거나, Spring Security의 CookieCsrfTokenRepository처럼 안전한 방식을 참고하세요.)

import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;

public class HttpCookieOAuth2AuthorizationRequestRepository
        implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {

    public static final String OAUTH2_AUTH_REQUEST_COOKIE_NAME = "oauth2_auth_request";
    private static final int COOKIE_EXPIRE_SECONDS = 180;

    @Override
    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
        return CookieUtils.getCookie(request, OAUTH2_AUTH_REQUEST_COOKIE_NAME)
                .map(Cookie::getValue)
                .map(this::deserialize)
                .orElse(null);
    }

    @Override
    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest,
                                         HttpServletRequest request,
                                         HttpServletResponse response) {
        if (authorizationRequest == null) {
            CookieUtils.deleteCookie(request, response, OAUTH2_AUTH_REQUEST_COOKIE_NAME);
            return;
        }
        String value = serialize(authorizationRequest);
        boolean secure = request.isSecure();
        CookieUtils.addCookie(response, OAUTH2_AUTH_REQUEST_COOKIE_NAME, value, COOKIE_EXPIRE_SECONDS, secure);

        // SameSite=None; Secure가 필요하면 response header로 Set-Cookie를 직접 구성하는 방식 고려
    }

    @Override
    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request,
                                                                HttpServletResponse response) {
        OAuth2AuthorizationRequest req = loadAuthorizationRequest(request);
        CookieUtils.deleteCookie(request, response, OAUTH2_AUTH_REQUEST_COOKIE_NAME);
        return req;
    }

    private String serialize(OAuth2AuthorizationRequest obj) {
        try {
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            ObjectOutputStream oos = new ObjectOutputStream(bos);
            oos.writeObject(obj);
            oos.close();
            return Base64.getUrlEncoder().encodeToString(bos.toByteArray());
        } catch (IOException e) {
            throw new IllegalStateException(e);
        }
    }

    private OAuth2AuthorizationRequest deserialize(String cookie) {
        try {
            byte[] bytes = Base64.getUrlDecoder().decode(cookie);
            ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes));
            Object obj = ois.readObject();
            return (OAuth2AuthorizationRequest) obj;
        } catch (IOException | ClassNotFoundException e) {
            throw new IllegalStateException(e);
        }
    }
}

Security 설정에 적용 (Spring Security 6 기준)

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public HttpCookieOAuth2AuthorizationRequestRepository cookieAuthorizationRequestRepository() {
        return new HttpCookieOAuth2AuthorizationRequestRepository();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http,
                                           HttpCookieOAuth2AuthorizationRequestRepository repo) throws Exception {

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

        return http.build();
    }
}

이 방식은 멀티 인스턴스에서 세션 공유 없이도 state mismatch를 크게 줄여줍니다.

5) 해결 전략 B: 프록시 환경에서 redirect_uri/baseUrl 정합성 맞추기

ALB/Nginx/Ingress 뒤에서 redirect_uri가 http로 만들어지거나 호스트가 내부 주소로 잡히면, Provider에서 돌아오는 경로가 달라지고 쿠키/세션이 어긋날 수 있습니다.

(1) Forwarded 헤더 처리 활성화

Spring Boot 3.x에서는 보통 아래 설정으로 해결됩니다.

# application.properties
server.forward-headers-strategy=framework

인그레스가 X-Forwarded-Proto: https 등을 넣어주는지 확인하세요.

(2) ALB/Ingress 설정 점검

  • X-Forwarded-Proto, X-Forwarded-Host가 제대로 전달되는지
  • 외부 도메인과 내부 서비스 도메인이 섞여 있지 않은지
  • 콜백 URL이 항상 동일한 호스트/스킴으로 귀결되는지

리다이렉트가 꼬이면 state mismatch뿐 아니라 무한 리다이렉트로도 보이는데, 이 경우는 앞서 언급한 내부 글(Spring Security OAuth2 리다이렉트 루프 끊는 법)이 직접적으로 도움이 됩니다.

6) 해결 전략 C: 세션을 계속 쓸 거라면 “공유 세션”으로

세션 기반을 유지하고 싶다면(예: 쿠키에 AuthorizationRequest를 저장하기 싫은 정책), 서버가 여러 대일 때는 세션을 공유해야 합니다.

대표적으로 Spring Session + Redis를 사용합니다.

dependencies {
    implementation 'org.springframework.session:spring-session-data-redis'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}
spring.session.store-type=redis
spring.data.redis.host=localhost
spring.data.redis.port=6379

이렇게 하면 인가 요청이 A 서버에서 저장되어도 B 서버에서 동일 세션을 조회할 수 있어 authorization_request_not_found가 줄어듭니다.

단, SameSite/도메인/HTTPS 문제로 세션 쿠키 자체가 콜백에 안 붙는 케이스는 Redis로도 해결되지 않습니다. 그 경우는 쿠키 정책/프록시/도메인을 먼저 잡아야 합니다.

7) 쿠키 SameSite/도메인/HTTPS 실전 권장값

운영에서 OAuth2 로그인은 보통 HTTPS가 전제입니다.

  • Secure=true (HTTPS에서만 쿠키 전송)
  • 크로스사이트 콜백이 필요하면 SameSite=None 필요
  • SameSite=None반드시 Secure와 세트

Spring Boot/서블릿 기본 쿠키 설정만으로 SameSite를 완벽히 제어하기 어려운 경우가 있어, 인그레스(Nginx)나 애플리케이션에서 Set-Cookie 헤더를 직접 구성하기도 합니다.

또한 도메인이 여러 개라면:

  • 로그인 시작 URL과 콜백 URL을 같은 호스트로 통일
  • 불가피하면 쿠키 Domain=.example.com 전략을 검토(보안 영향 검토 필수)

8) 빠른 진단 플로우 (10분 컷)

  1. 브라우저 개발자 도구 → Network에서 콜백 요청에 세션 쿠키(JSESSIONID 등) 또는 oauth2 관련 쿠키가 붙는지 확인
  2. 콜백의 state 파라미터가 존재하는지 확인
  3. 서버 로그에서 authorization_request_not_found인지, state mismatch인지 구분
  4. 멀티 인스턴스면 콜백이 다른 파드/인스턴스로 가는지 확인(Access log에 upstream/pod 정보 포함)
  5. X-Forwarded-Proto/Host가 들어오는지 확인하고 server.forward-headers-strategy 적용

9) 결론: “저장소”와 “라우팅/쿠키 정책”이 전부다

Spring Security OAuth2의 state 불일치 401은 라이브러리 버그라기보다, 인가 요청 저장소(세션/쿠키)와 콜백 요청이 같은 컨텍스트로 돌아오지 못해서 발생하는 구성 문제인 경우가 대부분입니다.

  • 스케일아웃/클라우드 환경: 쿠키 기반 AuthorizationRequestRepository 또는 Redis 세션 공유
  • 프록시/ALB 환경: Forwarded 헤더 처리로 redirect_uri 정합성 확보
  • 브라우저 정책: SameSite=None; Secure 및 도메인/호스트 통일

위 3가지를 정리하면, 간헐적으로만 재현되던 state mismatch 401도 안정적으로 사라지는 경우가 많습니다.