스프링 시큐리티를 붙인 뒤 GET 은 되는데 POST·PUT·DELETE 만 403 이 나면, 대부분 인가 규칙이 아니라 CSRF 검사에서 막힌 거예요. 결론부터 말하면 서버 로그로 CSRF 인지 먼저 가리고, 보낸 토큰 길이가 36자(쿠키 원본)인지 96자(마스킹 값)인지 보고, 그 값이 설정과 짝이 맞는지 확인하면 원인 대부분이 좁혀져요. 설정은 "화면을 누가 그리나"와 "SPA 와 API 가 같은 출처인가" 두 질문으로 거의 정해져요.
이 글은 필터 내부 대신 어떤 설정을 언제 고르고, 403 이 나면 무엇부터 보는지를 다뤄요. 기준은 Spring Security 7.1.2 이고, 숫자는 Apple M1·OpenJDK 24.0.2 에서 7.1.2 jar 의 CsrfFilter 를 목 요청과 쿠키를 이어 싣는 가짜 브라우저로 직접 돌려 얻었어요(2026-11-06).
CSRF 설정 판단표, 끌지 켤지부터
첫 질문은 "브라우저가 인증 정보를 알아서 붙이느냐"예요. Spring Security 레퍼런스의 CSRF 기능 설명은 일반 사용자가 브라우저로 보낼 수 있는 요청이면 보호를 켜고, 브라우저가 아닌 클라이언트만 쓰는 서비스라면 끄는 것을 고려하라고 해요. JWT 냐 세션이냐는 기준이 아니에요.
| 인증 정보가 오는 자리 | 다른 사이트 페이지가 실어 보낼 수 있나 | 권장 |
|---|---|---|
| 세션 쿠키(폼 로그인) | 예, 브라우저가 자동 첨부 | 켜기 |
쿠키에 담은 JWT(HttpOnly) | 예, 세션 쿠키와 같음 | 켜기 |
| HTTP Basic | 예, 브라우저가 자격 증명을 기억 | 켜기 |
프런트 코드가 넣는 Authorization: Bearer | 아니오 | 끄는 것 고려 |
| 서버 간 호출, 모바일 앱 전용 API | 해당 없음 | 끄기 |
| 위 둘이 한 앱에 섞임 | 경로마다 다름 | 체인을 나누거나 경로별 제외 |
마지막 줄이 실무에서 흔해요. 관리자 화면(세션)과 외부 연동 API(Bearer)가 한 앱에 있으면 전부 끄지 말고 SecurityFilterChain 을 둘로 나누거나 ignoringRequestMatchers("/api/partner/**") 로 그 경로만 빼요. 서명으로 검증하는 웹훅도 같은 방식으로 빼요.
SameSite 쿠키로 대신할 수 있냐는 질문도 많아요. 같은 문서는 SameSite 를 보조 방어(defense in depth) 로 권해요. 스프링 부트에서는 server.servlet.session.cookie.same-site 속성으로 세션 쿠키에 붙일 수 있으니(스프링 부트 속성 목록), 토큰 검사와 같이 쓰는 쪽을 권해요.
저장소·핸들러 조합 고르기, 서버 렌더링과 SPA
켜기로 했다면 정답 토큰을 어디 두고(저장소) 보낸 값을 어떻게 읽을지(핸들러)를 화면 구조로 골라요.
| 화면 구조 | 저장소 | 핸들러 | 클라이언트가 보내는 값 | 이유 |
|---|---|---|---|---|
| 타임리프·JSP 서버 렌더링 | 세션(기본) | XOR(기본) | 폼 hidden _csrf 96자 | 손댈 것 없음 |
| 서버 렌더링 + 일부 fetch | 세션(기본) | XOR(기본) | <meta> 에 그린 96자를 X-CSRF-TOKEN 헤더로 | 레퍼런스의 멀티 페이지 예시 |
| 같은 출처 SPA(7.0 이상) | csrf.spa() | csrf.spa() | XSRF-TOKEN 쿠키 36자를 X-XSRF-TOKEN 헤더로 | Angular·axios 기본 이름과 같음 |
| 같은 출처 SPA(6.x) | 쿠키(withHttpOnlyFalse) | 헤더 유무로 위임하는 직접 만든 핸들러 | 위와 같음 | 6.x 에는 spa() 가 없음 |
| 다른 서브도메인 SPA | 세션(기본) | XOR(기본) | GET /csrf 응답의 96자를 X-CSRF-TOKEN 헤더로 | 쿠키 도메인을 넓히지 않아도 됨 |
spa() 가 무엇을 바꾸는지는 7.1.2 CsrfConfigurer 소스에 그대로 있어요. 저장소를 CookieCsrfTokenRepository.withHttpOnlyFalse() 로 바꾸고, 헤더에 값이 있으면 마스킹 없이 비교하고 없으면 XOR 로 풀어 비교하는 핸들러를 넣어요. 그래서 SPA 의 fetch(헤더)와 서버가 그린 폼(파라미터)이 한 앱에서 같이 동작해요.
다른 서브도메인 SPA 에 세션 저장소를 권하는 이유는 쿠키 범위예요. app.example.com 의 자바스크립트는 api.example.com 호스트 쿠키를 읽지 못해서, 쿠키 방식이면 Domain 을 example.com 으로 넓혀야 하고 그 순간 형제 서브도메인 전부가 그 쿠키를 읽고 덮어쓸 수 있어요(아래 "도입 전에 따질 것").
설정 조합별로 직접 돌려 본 결과
"쿠키 + XOR" 은 5.x 에서 쿠키 저장소만 쓰던 SPA 를 6.x 이상으로 올린 상태이고, spa() 열은 위 소스의 핸들러를 그대로 옮겨 만든 조합이에요.
| 시나리오 | 기본(세션 + XOR) | 쿠키 + XOR | spa() |
|---|---|---|---|
| GET 100번 동안 만들어진 세션 | 0개 | 0개 | 0개 |
GET 100번 동안 받은 Set-Cookie | 0번 | 0번 | 1번(첫 응답) |
| 쿠키 원본 36자를 헤더로 POST | — | 403 Invalid | 200 |
마스킹 값 96자를 _csrf 파라미터로 POST | 200 | 200 | 200 |
| 마스킹 값 96자를 헤더로 POST | 200 | 200 | 403 Invalid |
| 쿠키 없이 헤더만 POST | — | — | 403 Missing |
헤더 이름을 X-CSRF-TOKEN 으로 잘못 보냄 | — | — | 403 Invalid |
| 토큰 받은 뒤 세션 만료, 같은 값으로 POST | 403 Missing | — | — |
| 로그인 직후 옛 쿠키 값으로 POST | — | — | 403 Missing |
가장 쓸모 있는 줄은 "마스킹 값을 헤더로"예요. spa() 는 헤더가 있으면 원본끼리 비교해서, /csrf 엔드포인트에서 받은 96자 값을 헤더에 넣으면 거절돼요. 기본 설정에서는 그 값이 정답이고요. 같은 96자 값이 설정에 따라 정답도 오답도 되니 설정을 바꿀 때 프런트가 보내는 값부터 맞춰요.
두 번째는 "GET 만으로는 쿠키가 안 내려온다"예요. 토큰 지연 로딩 때문에 첫 화면이 GET 만 하면 XSRF-TOKEN 쿠키가 없고, 첫 POST 가 403 Missing 을 받은 뒤에야 그 응답으로 쿠키가 심어져요. "다시 누르면 되는데 첫 저장만 실패"하는 증상이에요. spa() 는 토큰을 매 요청 미리 읽게 해 첫 응답에서 쿠키를 내보내요.
세션 저장소는 토큰을 꺼내 쓰는 순간 세션을 만들어요. 실험에서도 GET 만 할 때는 0개였고 _csrf 를 읽자 세션이 생겼어요. 모든 페이지에 POST 폼이 있으면 비로그인 방문자도 세션을 하나씩 가져요. 부트 기본 세션 시간 30분(server.servlet.session.timeout)에 하루 24만 명이 고르게 오면 살아 있는 세션은 약 5,000개예요(24만 ÷ 48, 예시 계산). 레디스에 세션을 두면 이 수가 곧 키 수예요.
요청당 비용 계산, 바이트와 처리 시간
비용은 요청마다 실리는 바이트와 마스킹·복원 CPU 시간이에요. 원본은 UUID 문자열 36자, 마스킹 값은 난수 36바이트 + XOR 결과 36바이트 = 72바이트를 Base64URL 로 바꾼 96자예요(72 ÷ 3 × 4). 길이 관계는 Base64 인코더로 바로 확인돼요.
요청 100만 건 중 10%가 POST 라면 spa() 는 47MB + 5MB = 약 52MB, 기본 헤더 방식은 110바이트 × 10만 = 11MB 예요. HTTP/2 이상은 HPACK(RFC 7541) 동적 테이블로 반복 헤더가 몇 바이트로 줄 수 있어 실제 차이는 더 작아요.
CPU 는 마스킹 핸들러만 따로 200만 번씩 돌려 쟀어요. 두 번 돌린 값이 꽤 흔들려서 범위로 적어요.
나쁜 쪽 값으로 잡아도 마스킹 생성과 복원을 합쳐 요청당 약 5.3µs 예요. 초당 2,000건이면 2,000 × 5.3µs = 10.6ms, 코어 하나의 약 1% 예요. 필터 전체 비용은 목 요청 객체를 만드는 시간(약 6.6µs)과 구분되지 않았어요. 성능을 이유로 XOR(BREACH 방어)을 끄는 건 권하지 않아요.
POST 만 403 이 날 때 확인 순서, 체크리스트
인가 실패와 CSRF 실패는 같은 403 이에요(HTTP 상태 코드 조회). 둘을 가르는 건 서버 로그예요.
- CSRF 인지 확인.
logging.level.org.springframework.security.web.csrf=DEBUG를 켜고 다시 보내요. 7.1.2CsrfFilter는 실패할 때Invalid CSRF token found for <주소>를 DEBUG 로 찍어요. 없으면 인가 규칙 문제예요. - 예외 이름 보기. 커스텀
AccessDeniedHandler에서 예외 클래스를 찍어요.MissingCsrfTokenException은 서버에 정답이 없음(세션 만료·쿠키 안 옴),InvalidCsrfTokenException은 값이 다름이에요. - 헤더 이름 맞추기. 세션 저장소는
X-CSRF-TOKEN, 쿠키 저장소는X-XSRF-TOKEN을 읽어요. 폼 파라미터는 둘 다_csrf예요. - 값 길이 보기. 36자면 쿠키 원본, 96자면 마스킹 값이에요. 원본은
spa()헤더에서만, 마스킹 값은 XOR 핸들러와 폼 파라미터에서만 통과해요. XOR 핸들러가 원본을 받으면 TRACE 로그에Base64-decoded length (27) is not equal to (72)가 남아요(36자 ÷ 4 × 3 = 27바이트). - 쿠키가 실렸는지.
Cookie헤더에XSRF-TOKEN이 없으면 Missing 이에요. 쿠키Path가 컨텍스트 경로로 잡혔거나,Secure쿠키를http로 보내거나, 다른 출처 호출에credentials: 'include'를 빠뜨린 경우가 흔해요. - 직전 화면 흐름 보기. 로그인·로그아웃 성공 때 토큰이 지워져요. 실험에서 로그인 응답은
XSRF-TOKEN=; Max-Age=0이었고, 옛 값으로 보낸 POST 는 403 Missing 이었어요.
파일 업로드만 실패하면 별도 갈래예요. 레퍼런스의 서블릿 CSRF 장은 본문의 _csrf 를 읽으려면 MultipartFilter 를 시큐리티 앞에 둬야 하고, 그러면 누구나 임시 파일을 올릴 수 있다고 적어요. fetch 로 바꿔 헤더로 보내는 편이 깔끔해요.
운영 설정 예시, 쿠키 이름과 SameSite
spa() 의 쿠키를 더 조이려면 저장소를 직접 만들어 넘겨요. 7.x 의 CookieCsrfTokenRepository에는 도메인·Secure·만료를 따로 바꾸는 setter 가 없고, 6.1 부터 있는 setCookieCustomizer 로 한 번에 바꿔요.
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
CookieCsrfTokenRepository repo = CookieCsrfTokenRepository.withHttpOnlyFalse();
repo.setCookieName("__Host-XSRF-TOKEN"); // 프런트의 쿠키 이름도 같이 바꿔요
repo.setCookieCustomizer(c -> c.secure(true).path("/").sameSite("Strict"));
http.csrf(csrf -> csrf
.spa() // 저장소·핸들러 기본 세트
.csrfTokenRepository(repo) // 저장소만 위 설정으로 교체
.ignoringRequestMatchers("/webhooks/**")); // 서명으로 검증하는 경로만 제외
return http.build();
}
spa() 뒤에 csrfTokenRepository(...) 를 부르면 저장소만 바뀌고 SPA 핸들러는 남아요. 실험에서 이 쿠키는 __Host-XSRF-TOKEN=…; Path=/; Secure 에 SameSite=Strict 가 붙어 나왔어요. MDN Set-Cookie 문서대로 __Host- 쿠키는 Secure·Path=/·Domain 없음이어야 받아들여져서, 서브도메인이 덮어쓸 수 없어요. 다만 http 개발 서버에서는 Secure 때문에 저장이 안 될 수 있어 프로필별로 나눠요.
Angular·axios 모두 기본은 XSRF-TOKEN 쿠키 → X-XSRF-TOKEN 헤더예요. Angular 보안 문서는 이 헤더를 상대 주소·같은 출처로 가는 변경 요청에만 붙인다고 적어요. 다른 도메인 절대 주소로 부르면 헤더가 안 붙어서, 서버만 봐서는 찾기 어려워요.
도입 전에 따질 것, 쿠키 방식의 한계
쿠키 저장소는 OWASP 가 말하는 "단순 이중 제출 쿠키(naive double-submit cookie)" 예요. 쿠키와 헤더 값이 같은지만 보므로, 공격자가 쿠키를 심을 수 있으면 아는 값을 양쪽에 넣어 통과해요. OWASP CSRF 방어 치트시트는 취약한 형제 서브도메인, DNS 탈취, __Host- 가 아닌 쿠키에 대한 평문 HTTP 주입을 그 경로로 들고, 새 코드에는 세션에 묶은 서명 토큰 방식을 권해요.
그래서 도입 전에 이 네 가지를 확인해요.
- 형제 서브도메인 목록. 외부 서비스 CNAME, 방치된 호스트, 사용자 콘텐츠 호스트가 있으면 쿠키 도메인을 넓히지 말고 세션 저장소 +
/csrf로 가요. - HTTPS 전용 여부. 전부 HTTPS(HSTS 포함)면
__Host-이름을 써요. 평문 구간이 있으면 쿠키 주입 여지가 남아요. - 즉시 무효화 필요성. 쿠키 방식은 서버 상태가 없어 새어 나간 토큰을 강제로 무효화할 수 없어요. 세션 저장소는 세션만 지우면 돼요.
- XSS 대비. XSS 가 나면 어떤 CSRF 토큰도 읽혀요.
withHttpOnlyFalse()를 쓴다면 CSP 같은 대책을 같이 점검해요. 이스케이프 기본은 HTML 엔티티 이스케이프와 XSS 방어에 정리했어요.
토큰을 Bearer 헤더로 옮겨 CSRF 를 끄는 설계도 있지만, 그러면 토큰 저장 위치가 새 문제가 돼요. 위험은 JWT 보안 체크리스트를, 리소스 서버 쪽 401·403 순서는 스프링 시큐리티 JWT 리소스 서버 설정 판단표를 보세요.
자주 묻는 질문
스프링 시큐리티 6 으로 올린 뒤 SPA 의 POST 가 전부 403 이에요.
기본 핸들러가 XOR 마스킹으로 바뀌어서, 쿠키 원본 36자를 헤더로 보내면 거절돼요. 7.0 이상이면 csrf(csrf -> csrf.spa()) 로 바꾸고, 6.x 에 머물러야 하면 헤더가 있을 때만 원본으로 비교하는 핸들러를 직접 넣어요. 핸들러를 CsrfTokenRequestAttributeHandler 로만 바꾸는 방법은 BREACH 방어까지 꺼요.
XSRF-TOKEN 쿠키가 첫 화면에서 안 내려와요.
토큰 지연 로딩 때문이에요. GET 만 오가면 아무도 토큰을 꺼내지 않아 쿠키를 심지 않아요. spa() 는 매 요청 토큰을 미리 읽어 첫 응답에서 쿠키를 내려보내고, 직접 설정한다면 필터에서 CsrfToken 요청 속성의 getToken() 을 한 번 부르면 돼요.
JWT 를 쓰면 csrf.disable() 해도 되나요?
프런트 코드가 Authorization 헤더에 넣는다면 다른 사이트가 그 헤더를 만들 수 없어 꺼도 돼요. HttpOnly 쿠키에 담았다면 자동으로 실리므로 켜 둬요.
SameSite=Lax 를 켰는데 CSRF 토큰이 꼭 필요한가요?
스프링 시큐리티 레퍼런스는 SameSite 를 단독 방어가 아닌 보조 방어로 권해요. Lax 는 최상위 이동의 GET 에는 쿠키를 보내고, 같은 사이트로 묶이는 형제 서브도메인에서 오는 요청은 막지 못해요. 토큰 검사를 기본으로 두고 SameSite 를 더하는 구성이 안전해요.
로그인 직후 첫 POST 만 403 이 나요.
로그인 성공 때 기존 토큰이 지워지는데, SPA 가 로그인 전에 받아 둔 값을 계속 보내서예요. 실험에서는 403 Missing 이었어요. 로그인·로그아웃 응답 뒤에 쿠키를 다시 읽거나 /csrf 를 다시 불러 새 값을 쓰게 바꿔요.
정리
- 끌지 켤지는 "브라우저가 인증 정보를 자동으로 붙이나"로 정해요. 세션·쿠키 JWT·Basic 이면 켜고, 섞인 앱은 체인을 나누거나 경로별로 빼요.
- 서버 렌더링은 기본값, 같은 출처 SPA 는
spa(), 다른 서브도메인 SPA 는 세션 저장소 +/csrf엔드포인트가 출발점이에요. 형제 서브도메인을 다 믿을 수 없으면 쿠키 도메인을 넓히지 않아요. - 403 은 로그 → 예외 이름 → 헤더 이름 → 값 길이(36·96) → 쿠키 → 직전 로그인·로그아웃 순서로 좁혀요.
spa()에서 96자를 헤더로 보내면 거절돼요. - 비용은 요청당 수십~110바이트, 처리 수 µs 라 설정 선택의 기준이 아니에요. 기준은 쿠키를 누가 읽고 덮어쓸 수 있느냐예요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 스프링 시큐리티 CSRF 원리 — CsrfFilter·XOR 토큰·SPA에서 도식과 코드로 단계별로 풀었어요.