CSRF와 CORS 헷갈리지 않고 제대로 이해하기
요약: CSRF와 CORS는 이름도 비슷하고 보안·웹 요청과 관련되어 있어 혼동되기 쉽습니다. 하지만 두 개념은 방어 대상도, 동작 레이어도 전혀 다릅니다. 이 글에서는 CSRF와 CORS의 개념을 명확히 구분하고, 각각의 공격 원리와 방어 메커니즘을 살펴본 뒤, 실제 Spring Security 설정에서 어떻게 적용하는지까지 정리합니다.
시작하며
안녕하세요, 백엔드 개발을 공부하고 있는 아퀼라 입니다.
Spring Security를 처음 설정하다 보면 꼭 마주치는 두 가지가 있습니다. csrf().disable() 그리고 cors(). 둘 다 보안과 관련 있고, 둘 다 "설정 안 하면 요청이 막힌다"는 느낌을 주기 때문에 처음엔 그냥 한 세트처럼 느껴집니다. 저도 처음엔 "이유는 모르겠지만 disable하면 된다더라" 식으로 넘겼어요.
그런데 그러다 보면 어느 순간 문제가 생깁니다. 로그인은 되는데 API 호출이 막히거나, 개발 환경에서는 되는데 운영 환경에서는 안 되는 상황이요. 그 원인을 추적하다 보면 결국 CSRF와 CORS를 제대로 이해해야 한다는 사실에 맞닥뜨리게 됩니다.
이번 글에서는 "CSRF가 뭔지, CORS가 뭔지, 왜 다른지, Spring에서 어떻게 쓰는지"를 한 번에 정리해 보겠습니다.
왜 두 개념이 헷갈리나?
둘 다 이름에 "Cross"가 들어가고, 둘 다 "요청이 막힌다"는 증상으로 나타납니다. 하지만 관점이 다릅니다.
- CSRF (Cross-Site Request Forgery) — "이 요청, 정말 우리 사용자가 의도한 거 맞아?" 를 서버가 검증하는 것
- CORS (Cross-Origin Resource Sharing) — "이 출처에서 온 요청을 브라우저가 응답을 읽도록 허용해도 돼?" 를 브라우저가 판단하는 것
즉, CSRF는 **누가 보냈는가 (요청의 의도 검증)**에 관심이 있고, CORS는 **어디서 보냈는가 (요청의 출처 허용)**에 관심이 있습니다. 검증 주체도 CSRF는 서버, CORS는 브라우저로 완전히 다릅니다.
CSRF — 위조 요청 공격과 방어
공격 원리
CSRF는 사용자가 이미 로그인된 상태를 악용하는 공격입니다. 브라우저는 요청을 보낼 때 쿠키를 자동으로 첨부합니다. 악성 사이트에서 요청이 발생해도 세션 쿠키는 그대로 실려 나가기 때문에, 서버 입장에서는 "로그인된 사용자의 정상 요청"처럼 보입니다.
공격 흐름을 단계별로 살펴보겠습니다.
- 사용자가 내 서버에 로그인하여 세션 쿠키를 발급받습니다.
- 사용자가 악성 사이트를 방문합니다.
- 악성 사이트에는
<img src="<https://내서버/transfer?to=hacker&amount=100>">같은 태그가 삽입되어 있습니다. - 브라우저는 해당 URL로 요청을 보내면서 세션 쿠키를 자동으로 포함합니다.
- 서버는 정상 요청으로 인식하고 처리합니다 — 공격 성공.
방어 원리 — CSRF Token
핵심 아이디어는 간단합니다. 쿠키 외에, 서버만 알고 있는 토큰을 요청에 포함시키도록 강제하는 것입니다. 악성 사이트는 이 토큰을 알 수 없으므로 위조 요청이 차단됩니다.
방어 흐름은 다음과 같습니다.
- 사용자가 페이지를 요청합니다.
- 서버가 HTML과 함께 CSRF Token을 발급합니다.
- 사용자가
POST /transfer요청을 보낼 때, 쿠키 + CSRF Token을 함께 포함합니다. - 서버가 토큰의 유효성을 검증한 뒤 요청을 처리합니다.
Spring Security에서 CSRF
Spring Security는 기본적으로 CSRF 보호가 활성화되어 있습니다. 세션 기반 인증과 JWT 기반 인증에서 각각 다르게 설정합니다.
// 세션 기반 인증: CSRF 활성화 (기본값)
http.csrf { csrf ->
csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
}
// JWT 기반 Stateless 인증: CSRF 비활성화 가능
http.csrf { it.disable() }여기서 한 가지 궁금증이 생길 수 있습니다. JWT 환경에서 CSRF를 disable해도 괜찮은 걸까요?
CSRF 공격은 브라우저가 쿠키를 자동으로 전송하는 특성을 악용합니다. JWT를 Authorization: Bearer ... 헤더로 전달하면, 악성 사이트는 이 헤더를 삽입할 수 없으므로 CSRF 공격이 성립하지 않습니다. 따라서 JWT 기반 Stateless 환경에서는 CSRF를 disable해도 안전합니다.
단, JWT를 쿠키에 저장하는 경우에는 다시 CSRF에 취약해질 수 있으므로 주의가 필요합니다.
CORS — 출처 간 리소스 공유
동일 출처 정책 (Same-Origin Policy)
브라우저는 기본적으로 같은 출처(Origin)에서만 응답을 읽도록 제한합니다. 여기서 출처(Origin)란 프로토콜 + 호스트 + 포트의 조합을 의미합니다.
몇 가지 예시를 통해 같은 출처인지 확인해 보겠습니다. 기준 URL은 https://example.com입니다.
https://example.com/api→ ✅ 같은 출처 (경로만 다름)http://example.com→ ❌ 다른 출처 (프로토콜 다름)https://api.example.com→ ❌ 다른 출처 (호스트 다름)https://example.com:8080→ ❌ 다른 출처 (포트 다름)
Preflight 요청
브라우저는 실제 요청 전에 OPTIONS 메서드로 서버에 허가를 요청합니다. 이것이 Preflight입니다.
동작 흐름을 살펴보겠습니다.
- 브라우저가 서버에
OPTIONS /api/data요청을 보냅니다. 이때Origin: <https://front.example.com과>Access-Control-Request-Method: POST헤더를 포함합니다. - 서버가
200 OK와 함께Access-Control-Allow-Origin: <https://front.example.com>,Access-Control-Allow-Methods: POST헤더를 응답합니다. - 허가를 받은 브라우저가
POST /api/data실제 요청을 보냅니다. - 서버가 데이터와 함께
200 OK를 응답합니다.
여기서 중요한 점이 있습니다. Preflight는 브라우저가 자동으로 수행합니다. curl이나 Postman으로는 CORS 오류가 나지 않는 이유가 바로 여기에 있습니다. CORS는 브라우저의 보호 정책이기 때문입니다.
Spring Security에서 CORS
Spring Security에서 CORS를 설정하는 방법을 살펴보겠습니다.
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val config = CorsConfiguration()
config.allowedOrigins = listOf("<https://www.aquilaxk.site>") // 허용 출처
config.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS")
config.allowedHeaders = listOf("*")
config.allowCredentials = true // 쿠키/인증 헤더 포함 허용
val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", config)
return source
}
// SecurityFilterChain에 적용
http.cors { cors ->
cors.configurationSource(corsConfigurationSource())
}여기서 주의할 점이 있습니다. allowCredentials = true일 때 allowedOrigins = listOf("*")은 사용 불가입니다. 인증 정보가 포함된 요청에 와일드카드 Origin을 허용하면 브라우저가 차단합니다. 반드시 명시적인 출처를 지정해야 합니다.
실수하기 쉬운 포인트 정리
"CORS 설정했는데 왜 여전히 막혀요?"
가장 흔한 원인 세 가지를 점검해 보세요.
- Spring Security에
cors()설정을 했는가? — Security Filter Chain에 cors 설정이 빠져 있으면 요청이 필터 단에서 막힙니다. allowedOrigins에 정확한 출처가 있는가? — 포트·프로토콜을 포함한 정확한 문자열이 일치해야 합니다.allowCredentials = true이고 Origin이*인가? — 와일드카드 대신 명시적인 Origin으로 변경해야 합니다.
위 세 가지를 모두 확인했다면, Preflight OPTIONS 응답 헤더를 직접 확인해 보세요.
@CrossOrigin vs CorsConfigurationSource
CORS를 설정하는 두 가지 방법이 있습니다.
@CrossOrigin어노테이션 — 컨트롤러/메서드 단위로 적용됩니다. 특정 엔드포인트만 허용할 때 적합합니다.CorsConfigurationSourceBean — 전역(Security Filter 레벨)으로 적용됩니다. 전체 API에 일관된 CORS 정책을 적용할 때 적합합니다.
Spring Security를 사용할 경우, @CrossOrigin만 설정하고 Security Filter Chain에 cors() 설정을 빠뜨리면 Security 필터가 먼저 요청을 막아버립니다. Security 레벨에서도 반드시 CORS를 활성화해야 합니다.
www / api 도메인 분리 환경
프론트엔드가 https://www.example.com, 백엔드가 https://api.example.com으로 분리된 구조라면 이들은 다른 출처입니다. CORS 설정이 필수입니다.
반면, 같은 도메인에서 경로만 다르게 프록시 처리(/api → backend)하는 구조라면 CORS가 발생하지 않습니다.
CSRF vs CORS — 한눈에 비교
이제 두 개념의 차이점을 명확히 정리해 보겠습니다.
- 막으려는 것 — CSRF: 위조된 상태 변경 요청 / CORS: 허가되지 않은 응답 읽기
- 공격 시나리오 — CSRF: 악성 사이트가 피해자 세션으로 요청 전송 / CORS: 악성 사이트가 다른 사이트 응답 탈취
- 방어 위치 — CSRF: 서버 (토큰 검증) / CORS: 브라우저 (헤더 검사)
- GET 요청에도 적용? — CSRF: 일반적으로 GET은 제외 (상태 변경 없음) / CORS: 모든 Cross-Origin 요청에 적용
- Postman/curl에서? — CSRF: 토큰 없으면 막힘 / CORS: 오류 안 남 (브라우저 전용)
실제 설정 예시 — JWT 기반 Spring Security
아래는 JWT Stateless 환경에서의 전형적인 설정 패턴입니다. 앞서 살펴본 CSRF와 CORS 설정이 어떻게 조합되는지 확인해 보세요.
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
// JWT Stateless → CSRF 불필요
.csrf { it.disable() }
// CORS: 허용 출처 명시
.cors { cors ->
cors.configurationSource(corsConfigurationSource())
}
// 세션 사용 안 함
.sessionManagement { session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
}
// JWT 필터 등록
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter::class.java)
return http.build()
}
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val config = CorsConfiguration()
config.allowedOrigins = listOf(frontUrl, "<https://www.aquilaxk.site>")
config.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
config.allowedHeaders = listOf("Authorization", "Content-Type", "X-Requested-With")
config.allowCredentials = true
config.maxAge = 3600L // Preflight 캐시 1시간
val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", config)
return source
}위 설정에서 핵심 포인트를 정리하면 다음과 같습니다.
- CSRF: JWT 기반 Stateless 인증이므로
disable()처리합니다. - CORS: 프론트엔드 출처를 명시적으로 지정하고,
allowCredentials = true로 설정하여 인증 헤더를 포함한 요청을 허용합니다. - Preflight 캐시:
maxAge = 3600L로 설정하여 1시간 동안 Preflight 요청을 캐시합니다.
체크리스트 — 설정 전 확인사항
마지막으로, Spring Security에서 CSRF와 CORS를 설정하기 전에 확인해야 할 항목들을 정리합니다.
- 인증 방식이 세션 기반인가, JWT(Stateless) 기반인가? → CSRF 활성화 여부 결정
- 프론트엔드와 백엔드가 같은 Origin인가, 다른 Origin인가? → CORS 필요 여부 결정
allowCredentials = true라면allowedOrigins에 와일드카드 없는가?- Spring Security Filter Chain에
cors()설정을 명시했는가? - 허용할 Origin에 포트·프로토콜 포함 정확한 문자열을 넣었는가?
- 개발/스테이징/운영 환경별 Origin이 환경 변수로 분리되어 있는가?
마치며
이번 글에서는 CSRF와 CORS의 개념을 명확히 구분하고, 각각의 공격 원리와 방어 메커니즘, 그리고 Spring Security에서의 실제 설정 방법까지 살펴보았습니다.
결론은 단순합니다.
- CSRF는 *"요청이 진짜 우리 사용자의 의도인가"*를 서버가 검증하는 것 — JWT 환경에서는 대부분 disable 가능
- CORS는 *"이 출처에서 우리 응답을 읽어도 되는가"*를 브라우저가 판단하는 것 — 항상 명시적으로 설정 필요
- 둘은 서로 대체 관계가 아니라 완전히 다른 보안 계층입니다.
CSRF를 disable하고 CORS를 허용하면 보안이 뚫리는 게 아닙니다. 각각의 역할이 다르고, 각각의 적절한 설정이 있습니다. 이 글이 "일단 disable"에서 벗어나 이유를 알고 설정하는 데 도움이 됐으면 좋겠습니다.
궁금한 점이 있으시면 편하게 댓글 남겨 주세요!
