Spring Security CORS·CSRF 설정

왜 CORS·CSRF를 제대로 설정해야 하나?

프론트엔드와 백엔드가 분리된 SPA 아키텍처에서는 CORS(Cross-Origin Resource Sharing) 설정 없이는 API 호출 자체가 불가능합니다. 반면 CSRF(Cross-Site Request Forgery)는 쿠키 기반 인증에서 반드시 방어해야 할 공격 벡터입니다. Spring Security 6.x에서는 이 두 가지의 기본 동작이 변경되어, 정확한 이해 없이 설정하면 보안 허점이나 API 장애가 발생합니다.

CORS vs CSRF 핵심 차이

항목 CORS CSRF
목적 다른 도메인에서의 API 접근 허용 위조된 요청 차단
대상 브라우저의 Same-Origin Policy 쿠키 기반 인증 세션
Spring 기본값 모든 cross-origin 차단 POST/PUT/DELETE 보호
JWT API 명시적 허용 필요 비활성화 가능
쿠키 인증 credentials: true 설정 반드시 활성화

시나리오별 SecurityFilterChain 설정

1. SPA + JWT API (가장 일반적)

프론트엔드가 별도 도메인에서 JWT Bearer 토큰으로 인증하는 구조입니다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            // CORS: 명시적 허용
            .cors(cors -> cors.configurationSource(corsConfigSource()))

            // CSRF: JWT는 쿠키를 사용하지 않으므로 비활성화
            .csrf(csrf -> csrf.disable())

            // 세션 비사용
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            // JWT 인증
            .oauth2ResourceServer(oauth2 ->
                oauth2.jwt(Customizer.withDefaults()))

            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .build();
    }

    @Bean
    public CorsConfigurationSource corsConfigSource() {
        CorsConfiguration config = new CorsConfiguration();

        // 허용 Origin (와일드카드 X → 명시적 열거)
        config.setAllowedOrigins(List.of(
            "https://app.example.com",
            "https://admin.example.com"
        ));

        // 허용 메서드
        config.setAllowedMethods(List.of(
            "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
        ));

        // 허용 헤더
        config.setAllowedHeaders(List.of(
            "Authorization", "Content-Type", "X-Request-Id"
        ));

        // 응답에서 노출할 헤더
        config.setExposedHeaders(List.of(
            "X-Total-Count", "X-Page-Size", "Link"
        ));

        // Preflight 캐싱 (1시간)
        config.setMaxAge(3600L);

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

2. SPA + HttpOnly 쿠키 인증 (BFF 패턴)

쿠키 기반 인증에서는 CSRF를 반드시 활성화해야 합니다. Spring Security 6의 새로운 CSRF 토큰 핸들링을 사용합니다.

@Bean
public SecurityFilterChain cookieAuthFilterChain(HttpSecurity http)
        throws Exception {
    return http
        .cors(cors -> cors.configurationSource(corsConfigSource()))

        // CSRF: 쿠키 기반이므로 활성화
        .csrf(csrf -> csrf
            // SPA용: CSRF 토큰을 쿠키로 전달
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            // Spring Security 6: 지연 로딩 핸들러
            .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
        )

        // 쿠키 인증 CORS: credentials 허용
        // (CorsConfiguration에서 allowCredentials(true) 설정 필수)

        .formLogin(Customizer.withDefaults())
        .authorizeHttpRequests(auth -> auth
            .anyRequest().authenticated())
        .build();
}

// Spring Security 6 SPA CSRF 핸들러
public class SpaCsrfTokenRequestHandler
        extends CsrfTokenRequestAttributeHandler {

    private final CsrfTokenRequestHandler delegate =
        new XorCsrfTokenRequestAttributeHandler();

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       Supplier<CsrfToken> csrfToken) {
        // 항상 attribute에 토큰 세팅 (지연 로딩 트리거)
        this.delegate.handle(request, response, csrfToken);
    }

    @Override
    public String resolveCsrfTokenValue(HttpServletRequest request,
                                         CsrfToken csrfToken) {
        String headerValue = request.getHeader(csrfToken.getHeaderName());
        if (headerValue != null) {
            // XOR 인코딩된 헤더 값 검증
            return super.resolveCsrfTokenValue(request, csrfToken);
        }
        return this.delegate.resolveCsrfTokenValue(request, csrfToken);
    }
}

// CORS에 credentials 추가
private CorsConfigurationSource cookieCorsConfig() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://app.example.com"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
    config.setAllowedHeaders(List.of("*"));
    config.setAllowCredentials(true);  // 쿠키 전송 허용
    config.setMaxAge(3600L);

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

3. 내부 마이크로서비스 (CORS 불필요)

@Bean
@Order(1)
public SecurityFilterChain internalApiChain(HttpSecurity http)
        throws Exception {
    return http
        .securityMatcher("/internal/**")
        // 내부 통신: CORS·CSRF 모두 불필요
        .cors(cors -> cors.disable())
        .csrf(csrf -> csrf.disable())
        .authorizeHttpRequests(auth -> auth
            .anyRequest().hasIpAddress("10.0.0.0/8"))
        .build();
}

환경별 CORS 동적 설정

ConfigurationProperties로 환경별 Origin을 관리합니다.

@ConfigurationProperties(prefix = "app.cors")
@Validated
public record CorsProperties(
    @NotEmpty List<String> allowedOrigins,
    List<String> allowedMethods,
    List<String> allowedHeaders,
    List<String> exposedHeaders,
    boolean allowCredentials,
    long maxAge
) {
    public CorsProperties {
        if (allowedMethods == null)
            allowedMethods = List.of("GET", "POST", "PUT", "DELETE");
        if (maxAge == 0) maxAge = 3600L;
    }
}

// application-dev.yml
app:
  cors:
    allowed-origins:
      - http://localhost:3000
      - http://localhost:5173
    allow-credentials: false

// application-prod.yml
app:
  cors:
    allowed-origins:
      - https://app.example.com
      - https://admin.example.com
    allow-credentials: false
    max-age: 7200
@Bean
public CorsConfigurationSource corsConfigSource(CorsProperties props) {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(props.allowedOrigins());
    config.setAllowedMethods(props.allowedMethods());
    config.setAllowedHeaders(props.allowedHeaders() != null
        ? props.allowedHeaders() : List.of("*"));
    config.setExposedHeaders(props.exposedHeaders());
    config.setAllowCredentials(props.allowCredentials());
    config.setMaxAge(props.maxAge());

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

보안 응답 헤더

CORS·CSRF 외에도 보안 헤더를 설정하여 XSS, 클릭재킹 등을 방어합니다.

@Bean
public SecurityFilterChain securityHeaders(HttpSecurity http)
        throws Exception {
    return http
        .headers(headers -> headers
            // Content-Security-Policy
            .contentSecurityPolicy(csp -> csp
                .policyDirectives(
                    "default-src 'self'; " +
                    "script-src 'self' 'nonce-{random}'; " +
                    "style-src 'self' 'unsafe-inline'; " +
                    "img-src 'self' data: https:; " +
                    "connect-src 'self' https://api.example.com"))

            // X-Frame-Options → 클릭재킹 방지
            .frameOptions(frame -> frame.deny())

            // X-Content-Type-Options
            .contentTypeOptions(Customizer.withDefaults())

            // Strict-Transport-Security (HTTPS 강제)
            .httpStrictTransportSecurity(hsts -> hsts
                .includeSubDomains(true)
                .maxAgeInSeconds(31536000))

            // Referrer-Policy
            .referrerPolicy(referrer -> referrer
                .policy(ReferrerPolicyHeaderWriter.ReferrerPolicy
                    .STRICT_ORIGIN_WHEN_CROSS_ORIGIN))

            // Permissions-Policy
            .permissionsPolicy(permissions -> permissions
                .policy("camera=(), microphone=(), geolocation=(self)"))
        )
        // ... 나머지 설정
        .build();
}

CSRF 예외 패턴

Webhook, 외부 콜백 등 CSRF 검증을 제외해야 하는 엔드포인트를 설정합니다.

.csrf(csrf -> csrf
    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
    // 특정 경로 CSRF 제외
    .ignoringRequestMatchers(
        "/api/webhooks/**",           // 외부 웹훅
        "/api/payment/callback/**",   // PG사 콜백
        "/actuator/**"                // 모니터링
    )
)

Preflight 요청 이해

브라우저는 “단순 요청”이 아닌 경우 OPTIONS Preflight 요청을 먼저 보냅니다.

조건 단순 요청 Preflight 발생
메서드 GET, HEAD, POST PUT, DELETE, PATCH
Content-Type form-urlencoded, multipart, text/plain application/json
커스텀 헤더 없음 Authorization 등

maxAge를 설정하면 Preflight 결과를 캐싱하여 불필요한 OPTIONS 요청을 줄입니다.

테스트

MockMvc로 CORS·CSRF 동작을 검증합니다.

@WebMvcTest
class CorsSecurityTest {

    @Autowired MockMvc mockMvc;

    @Test
    void preflight_allowedOrigin_returns200() throws Exception {
        mockMvc.perform(options("/api/products")
                .header("Origin", "https://app.example.com")
                .header("Access-Control-Request-Method", "POST")
                .header("Access-Control-Request-Headers", "Authorization"))
            .andExpect(status().isOk())
            .andExpect(header().string(
                "Access-Control-Allow-Origin", "https://app.example.com"))
            .andExpect(header().string(
                "Access-Control-Allow-Methods", containsString("POST")));
    }

    @Test
    void preflight_unknownOrigin_returns403() throws Exception {
        mockMvc.perform(options("/api/products")
                .header("Origin", "https://evil.com")
                .header("Access-Control-Request-Method", "POST"))
            .andExpect(status().isForbidden());
    }

    @Test
    void csrf_withToken_succeeds() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/csrf-token"))
            .andReturn();

        String csrfToken = result.getResponse()
            .getHeader("X-CSRF-TOKEN");

        mockMvc.perform(post("/api/orders")
                .header("X-CSRF-TOKEN", csrfToken)
                .contentType(MediaType.APPLICATION_JSON)
                .content("{"item":"test"}"))
            .andExpect(status().isOk());
    }

    @Test
    void csrf_withoutToken_returns403() throws Exception {
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{"item":"test"}"))
            .andExpect(status().isForbidden());
    }
}

운영 체크리스트

항목 확인 사항
CORS Origin 와일드카드(*) 지양, 명시적 도메인 열거
CSRF + JWT 쿠키 미사용 시 disable, 쿠키 사용 시 반드시 활성화
credentials 쿠키 전송 시 allowCredentials(true) + Origin 명시
Preflight 캐싱 maxAge로 OPTIONS 요청 최소화
보안 헤더 CSP, HSTS, X-Frame-Options 설정
Webhook 예외 외부 콜백 경로 CSRF ignoringRequestMatchers

마치며

Spring Security의 CORS·CSRF는 “왜 안 되지?”와 “왜 뚫리지?”의 양극단을 오가는 대표적인 설정입니다. JWT API는 CORS 명시 허용 + CSRF 비활성화, 쿠키 인증은 CORS credentials + CSRF 활성화라는 원칙을 기억하면 대부분의 시나리오를 커버합니다. 보안 응답 헤더까지 결합하면 프로덕션 레벨의 방어 체계를 구축할 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux