[친구하자] 크로스 도메인 환경에서 Refresh Token 쿠키 소실 문제 #2

백엔드, 프론트 도메인이 다른 크로스 도메인의 Production 환경에서 Refresh Token이 사라지는 문제에 대해 정리해보았습니다!

크로스 도메인 환경에서 Refresh Token 쿠키 소실 트러블슈팅 2

저번 편에서 해결까지는 다뤘지만, 그로 인해 발생하는 CSRF 공격에 대한 약한 방어를 방지하는 로직에 대해서는 이번 편에서 다루기로 한다. (너무 길어져서..)

CSRF 공격 방어

  • SamSite=None으로 하면 CSRF 공격을 방어할 수 없다. 따라서 추가 설정을 해줘야 한다.
  • 방어 방법들은 아래와 같다.

1. CSRF 토큰 패턴

// SecurityConfig.java
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
            )
            // ... 나머지 설정
            .build();
    }
}
// 프론트엔드
const csrfToken = getCookie("XSRF-TOKEN");

axios.post("/api/v1/users/delete", data, {
  headers: {
    "X-XSRF-TOKEN": csrfToken, // CSRF 토큰 포함
  },
});
  • 이렇게 하면 악의적인 사이트는
    • JavaScript로 쿠키를 읽을 수 없음
    • 따라서 헤더에 토큰을 포함할 수 없음

2. Origin/Referer 검증

  • 서버에서 요청의 Origin 또는 Referer 헤더를 확인하여 신뢰할 수 있는 도메인에서 온 요청인지 검증
// CorsConfig.java
@Bean
@Profile("prod")
public CorsConfigurationSource prodCorsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();

    // ✅ 허용된 출처만 명시
    configuration.setAllowedOriginPatterns(Arrays.asList(
        "https://chingoo-frontend.vercel.app",
        "https://www.chingoo-frontend.vercel.app"
    ));

    configuration.setAllowCredentials(true);
    // ...
}

이렇게 하면

DELETE https://your-backend.com/api/v1/users/delete
Origin: https://evil.com  ← 이 값으로 검증!
Cookie: refreshToken=...
  • 백엔드가 Origin 헤더 확인:
    • evail.com은 허용 목록에 없음
    • CORS 오류 발생으로 요청 차단
  • 이 방법의 단점이 있다.
    • Origin 헤더를 조작할 수 있는 환경에서는 우회 가능
    • 브라우저가 아닌 요청(Postman, curl)에서는 검증 안됨

3. JWT의 이중 검증

  • 가장 현대적인 방법이다.
// JwtAuthenticationFilter.java
@Override
protected void doFilterInternal(HttpServletRequest request,
                                HttpServletResponse response,
                                FilterChain filterChain) {

    // 1️⃣ 쿠키에서 RefreshToken 추출
    String refreshTokenFromCookie = extractTokenFromCookie(request);

    // 2️⃣ 헤더에서 AccessToken 추출
    String accessTokenFromHeader = extractTokenFromHeader(request);

    // 3️⃣ 둘 다 검증
    if (isValidToken(refreshTokenFromCookie) &&
        isValidToken(accessTokenFromHeader)) {

        // 4️⃣ 토큰의 userId가 일치하는지 확인
        Long userIdFromRefresh = getUserIdFromToken(refreshTokenFromCookie);
        Long userIdFromAccess = getUserIdFromToken(accessTokenFromHeader);

        if (userIdFromRefresh.equals(userIdFromAccess)) {
            // ✅ 인증 성공
            SecurityContextHolder.getContext()
                .setAuthentication(createAuthentication(userIdFromAccess));
        }
    }

    filterChain.doFilter(request, response);
}
  • 쿠키(Refresh Token)이 자동으로 포함됨
  • 하지만 해더(Access Token)는 JavaScript로 명시적으로 설정해야함
  • 악의적인 사이트는 정상 사이트의 localStorage에 접근 불가 (Cross-Origin)
  • 따라서 Access Token을 얻을 수 없음

4. SamSite + CSRF 토큰 조합

@Configuration
public class SecurityConfig {

    @Bean
    @Profile("prod")
    public SecurityFilterChain prodFilterChain(HttpSecurity http) {
        return http
            // CSRF 토큰 활성화
            .csrf(csrf -> csrf
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            )
            // CORS 설정 (Origin 검증)
            .cors(cors -> cors.configurationSource(corsConfigurationSource))
            // ...
            .build();
    }
}
# application-prod.yml
app:
  cookie:
    secure: true
    same-site: None # 크로스 도메인 지원
    max-age: 2592000
  • 이렇게 하면 다중 방어선이 만들어진다.
    • Origin 검증 (CORS)
    • CSRF 토큰 검증
    • JWT 검증