CORS와 CSRF, 무엇을 언제 막아야 하는가
이름이 비슷해서 그런지 CORS와 CSRF를 같은 문제로 착각하는 경우를 자주 봅니다. 하나는 브라우저가 "다른 출처의 서버가 응답을 읽어가게 둘 것인가"를 판단하는 규칙이고, 다른 하나는 "다른 사이트가 사용자 몰래 우리 서버에 요청을 보내는 것"을 막는 방어입니다. 둘 다 "다른 출처"가 관련되어 있다는 것만 같을 뿐, 막는 대상이 정반대입니다.
| CORS | CSRF | |
|---|---|---|
| 무엇을 막는가 | 응답을 다른 출처의 JS가 읽는 것 | 사용자 몰래 요청이 전송되는 것 |
| 누구의 규칙인가 | 브라우저 + 서버가 허용한 출처 목록 | 서버가 발급한 토큰 검증 |
| 끄면 생기는 일 | 아무 출처에서나 우리 API 응답을 읽어감 | 로그인된 사용자를 대신해 악성 사이트가 요청을 위조 |
| 세션 방식에서 | 필요 | 반드시 필요 |
| JWT + Authorization 헤더 방식에서 | 필요 | 사실상 불필요 |
CORS: 브라우저의 응답 읽기 제한
프런트엔드가 https://app.example.com, API 서버가 https://api.example.com처럼 출처가 다르면, 브라우저는 기본적으로 JS가 그 응답을 읽지 못하게 막습니다. 서버가 Access-Control-Allow-Origin 헤더로 명시적으로 허용해야 합니다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.cors(cors -> cors.configurationSource(corsConfigurationSource()));
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}setAllowedOrigins(List.of("*"))로 전체 허용은 편해 보이지만, setAllowCredentials(true)(쿠키나 인증 헤더를 실어 보내는 요청 허용)와 와일드카드 출처는 스펙상 함께 쓸 수 없습니다. 스프링이 이 조합을 만나면 예외를 던지거나 크롬이 요청 자체를 거부합니다. 인증이 필요한 API라면 출처를 정확히 나열하는 수밖에 없습니다.
CORS는 브라우저가 지키는 약속이라는 점도 기억해둘 만합니다. curl이나 서버 간 통신, 포스트맨에서는 CORS가 적용되지 않습니다. "포스트맨으로는 되는데 브라우저에서만 막힌다"는 증상이 CORS 문제라는 걸 알아채는 가장 흔한 신호입니다.
CSRF: 요청 위조 방지
사용자가 우리 사이트에 로그인한 세션 쿠키를 브라우저에 갖고 있는 상태에서, 악성 사이트가 <img src="https://our-bank.com/transfer?to=attacker&amount=1000000"> 같은 요청을 몰래 실어 보내면 브라우저는 쿠키를 자동으로 함께 보냅니다. 서버 입장에서는 정상 사용자의 요청과 구분할 방법이 없습니다 — 쿠키가 유효하니까요. CSRF 토큰은 이 문제를 막습니다. 폼이나 헤더에 서버만 아는 토큰을 함께 실어 보내야 요청이 통과하도록 강제하고, 악성 사이트는 이 토큰 값을 알 방법이 없습니다.
스프링 시큐리티는 세션 기반 인증에서 CSRF 보호를 기본으로 켭니다. 2장과 5장 예제에서 csrf(csrf -> csrf.disable())를 썼던 걸 기억하실 텐데, 그건 JWT를 Authorization 헤더로 실어 보내는 방식이 애초에 이 공격에 노출되지 않기 때문입니다. CSRF 공격의 전제는 "브라우저가 자동으로 인증 정보를 실어 보낸다"는 것인데, Authorization 헤더는 브라우저가 자동으로 채워주지 않습니다. 프런트엔드 JS 코드가 명시적으로 헤더에 토큰을 넣어야만 요청이 인증됩니다 — 그러니 악성 사이트가 몰래 흉내 낼 수 없습니다.
세션 인증을 쓰면서 CSRF를 꺼서는 안 됩니다. 반대로 JWT를 Authorization 헤더 방식으로 쓰면서 CSRF를 켜두는 것도 의미가 없습니다 — 애초에 막을 공격 대상이 없는데 토큰 검증 로직만 코드에 남습니다. 두 설정은 인증 방식이 정하는 것이지, "일단 꺼두면 편하니까"로 결정할 게 아닙니다.