왜 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 활성화라는 원칙을 기억하면 대부분의 시나리오를 커버합니다. 보안 응답 헤더까지 결합하면 프로덕션 레벨의 방어 체계를 구축할 수 있습니다.