광고
~ / posts / developer
💻 개발자

스프링 시큐리티 CSRF 403 확인 순서, SPA 쿠키 설정 판단표

스프링 시큐리티를 붙인 뒤 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 헤더로쿠키 도메인을 넓히지 않아도 됨
인증 정보 자동 첨부, 서버 렌더링, 같은 출처, 서브도메인 신뢰를 차례로 물어 CSRF 끄기·기본값·csrf.spa()·쿠키 Domain 공유·/csrf 엔드포인트 중 하나로 가는 순서도
위에서부터 물어 처음 닿는 칸이 출발점이에요

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)쿠키 + XORspa()
GET 100번 동안 만들어진 세션0개0개0개
GET 100번 동안 받은 Set-Cookie0번0번1번(첫 응답)
쿠키 원본 36자를 헤더로 POST—403 Invalid200
마스킹 값 96자를 _csrf 파라미터로 POST200200200
마스킹 값 96자를 헤더로 POST200200403 Invalid
쿠키 없이 헤더만 POST——403 Missing
헤더 이름을 X-CSRF-TOKEN 으로 잘못 보냄——403 Invalid
토큰 받은 뒤 세션 만료, 같은 값으로 POST403 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 인코더로 바로 확인돼요.

CSRF 토큰 때문에 요청 하나에 더 실리는 바이트
spa() 쿠키(모든 요청)spa() 쿠키(모든 요청) · 47바이트47바이트spa() 헤더(POST만)spa() 헤더(POST만) · 50바이트50바이트__Host- 이름 쿠키(모든 요청)__Host- 이름 쿠키(모든 요청) · 54바이트54바이트기본 폼 파라미터(POST만)기본 폼 파라미터(POST만) · 102바이트102바이트기본 헤더(POST만)기본 헤더(POST만) · 110바이트110바이트

쿠키 방식은 GET 에도 실리고, 세션 방식은 상태를 바꾸는 요청에만 실려요

단위: 바이트 · 자료: 이름 + 구분자 + 값 길이로 직접 계산 (원본 36자, 마스킹 값 96자, HTTP/1.1 비압축 기준)
표로 보기
구분바이트
spa() 쿠키(모든 요청)47
spa() 헤더(POST만)50
__Host- 이름 쿠키(모든 요청)54
기본 폼 파라미터(POST만)102
기본 헤더(POST만)110

요청 100만 건 중 10%가 POST 라면 spa() 는 47MB + 5MB = 약 52MB, 기본 헤더 방식은 110바이트 × 10만 = 11MB 예요. HTTP/2 이상은 HPACK(RFC 7541) 동적 테이블로 반복 헤더가 몇 바이트로 줄 수 있어 실제 차이는 더 작아요.

CPU 는 마스킹 핸들러만 따로 200만 번씩 돌려 쟀어요. 두 번 돌린 값이 꽤 흔들려서 범위로 적어요.

토큰 처리 한 번에 드는 시간 (Spring Security 7.1.2, Apple M1)
1차 측정2차 측정
05001,0001,5002,0002,5003,000마스킹 값 만들기 · 1차 측정 2,364나노초마스킹 값 만들기 · 2차 측정 2,681나노초마스킹 값 만들기마스킹 값 풀기 · 1차 측정 927나노초마스킹 값 풀기 · 2차 측정 2,630나노초마스킹 값 풀기원본 그대로 읽기 · 1차 측정 158나노초원본 그대로 읽기 · 2차 측정 273나노초원본 그대로 읽기

마스킹 값 만들기는 SecureRandom 난수 36바이트가 대부분이에요

단위: 나노초 · 자료: XorCsrfTokenRequestAttributeHandler·CsrfTokenRequestAttributeHandler 를 각 200만 번 호출해 직접 측정 (2026-11-06)
표로 보기
구분1차 측정2차 측정
마스킹 값 만들기2,3642,681
마스킹 값 풀기9272,630
원본 그대로 읽기158273

나쁜 쪽 값으로 잡아도 마스킹 생성과 복원을 합쳐 요청당 약 5.3µs 예요. 초당 2,000건이면 2,000 × 5.3µs = 10.6ms, 코어 하나의 약 1% 예요. 필터 전체 비용은 목 요청 객체를 만드는 시간(약 6.6µs)과 구분되지 않았어요. 성능을 이유로 XOR(BREACH 방어)을 끄는 건 권하지 않아요.

POST 만 403 이 날 때 확인 순서, 체크리스트

인가 실패와 CSRF 실패는 같은 403 이에요(HTTP 상태 코드 조회). 둘을 가르는 건 서버 로그예요.

POST 만 403 일 때 로그, 예외 이름, 헤더 이름, 값 길이 36자·96자, 쿠키 전송, 직전 로그인·로그아웃을 차례로 보는 6단계 체크리스트
1~2는 서버 로그, 3~5는 브라우저 네트워크 탭, 6은 화면 흐름에서 확인해요
  1. CSRF 인지 확인. logging.level.org.springframework.security.web.csrf=DEBUG 를 켜고 다시 보내요. 7.1.2 CsrfFilter 는 실패할 때 Invalid CSRF token found for <주소> 를 DEBUG 로 찍어요. 없으면 인가 규칙 문제예요.
  2. 예외 이름 보기. 커스텀 AccessDeniedHandler 에서 예외 클래스를 찍어요. MissingCsrfTokenException 은 서버에 정답이 없음(세션 만료·쿠키 안 옴), InvalidCsrfTokenException 은 값이 다름이에요.
  3. 헤더 이름 맞추기. 세션 저장소는 X-CSRF-TOKEN, 쿠키 저장소는 X-XSRF-TOKEN 을 읽어요. 폼 파라미터는 둘 다 _csrf 예요.
  4. 값 길이 보기. 36자면 쿠키 원본, 96자면 마스킹 값이에요. 원본은 spa() 헤더에서만, 마스킹 값은 XOR 핸들러와 폼 파라미터에서만 통과해요. XOR 핸들러가 원본을 받으면 TRACE 로그에 Base64-decoded length (27) is not equal to (72) 가 남아요(36자 ÷ 4 × 3 = 27바이트).
  5. 쿠키가 실렸는지. Cookie 헤더에 XSRF-TOKEN 이 없으면 Missing 이에요. 쿠키 Path 가 컨텍스트 경로로 잡혔거나, Secure 쿠키를 http 로 보내거나, 다른 출처 호출에 credentials: 'include' 를 빠뜨린 경우가 흔해요.
  6. 직전 화면 흐름 보기. 로그인·로그아웃 성공 때 토큰이 지워져요. 실험에서 로그인 응답은 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 주입을 그 경로로 들고, 새 코드에는 세션에 묶은 서명 토큰 방식을 권해요.

그래서 도입 전에 이 네 가지를 확인해요.

토큰을 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 를 다시 불러 새 값을 쓰게 바꿔요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 스프링 시큐리티 CSRF 원리 — CsrfFilter·XOR 토큰·SPA에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#스프링 시큐리티 CSRF 403#csrf.spa() 설정#CookieCsrfTokenRepository SPA#InvalidCsrfTokenException 원인#MissingCsrfTokenException#XSRF-TOKEN 쿠키 안 내려옴#csrf disable 해도 되나#Spring Security 7
← 이전 글스프링 배치 retry·skip 고르는 법, 예외 분류표와 재시도 비용다음 글 →AWS NAT 게이트웨이 비용 줄이기, AZ별 배치·엔드포인트 판단표
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →