2025. 11. 24. 14:30ㆍSpring Security/Practical
CORS 허용 설정을 통해 다른 출처(Origin)의 접근을 허용할 수 있습니다.
하지만 GET외의 요청은 CSRF 차단하므로, 사용자 인증/인가와는 별개로 403 Forbidden이 발생합니다.
이번에는 CSRF가 무엇인지 되짚어보고, Spring Security에서의 기본 CSRF 동작 이해와 Refresh Token에 CSRF 설정이 필요한 이유 및 설정 방법에 대해 다룹니다.
- CSRF (Cross Site Request Forgery) 란?
- Refresh Token의 CSRF 보호가 필요한 이유
- Spring Security의 CSRF 기본 설정
- REST + JWT(Refresh Token Rotation)의 CSRF 설정
- CSRF 핸들러 설정: CsrfTokenRequestHandler
- 회고
- 참고
CSRF (Cross Site Request Forgery) 란?
다른 도메인에서 사용자의 권한을 이용해 사용자가 의도하지 않은 악의적 요청을 하는 공격 기법
사이트 간 요청 위조(CSRF)는 브라우저가 웹 요청 시 자동으로 쿠키를 포함해 전송하는 특성을 악용한 공격 기법입니다.
따라서 세션과 토큰과 같은 구조적 차이가 아닌, 인증 정보로 자동 포함하는 방식인지의 여부에 따라 CSRF 보호 여부를 결정해야 합니다.
[ GET 요청은 CSRF 대상이 아니다 ]
GET 요청은 리소스를 조회하는 안전한(safe) 메서드로 정의되며, 요청을 여러 번 반복하더라도 서버의 상태를 변경해서는 안 됩니다.(idempotent). 따라서 GET 요청은 본질적으로 서버의 자원을 “조회(read-only)”하는 용도로, CSRF의 보호 대상에서 제외됩니다.

Refresh Token의 CSRF 보호가 필요한 이유
다른 출처(Origin)에서의 REST + JWT(Refresh Token Rotation)을 허용할 때, Refresh Token이 CSRF 보호 대상인지 알아보겠습니다.
[ Access Token ]
비즈니스 로직의 엑세스 토큰으로 짧은 생명 주기를 가집니다.
웹 요청 시 헤더에 Authorization: Bearer {token} 와 같은 형식으로 실려 전달됩니다.
브라우저는 자동으로 헤더를 포함하지 않으므로, CSRF 보호 대상이 아닙니다.
[ Refresh Token ]
Access Token 재발급 용도의 토큰으로 긴 생명 주기를 가집니다.
웹 요청 시 쿠키에 담으며, XSS 공격과 같이 보안적 이유로 아래의 옵션을 사용합니다.
- HttpOnly
- SameSite=None(same-site, cross-origin이면 Lax)
- Secure
Refresh Token은 쿠키에 담아 전송되므로, CSRF 보호 대상이 됩니다.
[ Refresh Token에 CSRF 보호가 필요한 이유 ]
Refresh Token이 쿠키에 담겨 전송된다는 이유만으로 정말 CSRF 보호가 필요할까요?
오직 Access Token 재발급만 가능하며 응답 역시 피해자의 브라우저로 전당되므로, 겉보기에는 CSRF 비활성화가 문제 없을 것 같습니다.
CSRF는 공격자가 피해자 브라우저를 통해 서버의 상태를 원치 않는 방식으로 변경시키는게 핵심입니다.
따라서 아래와 같은 이유로 Refresh Token도 CSRF 보호가 필요합니다.
- 공격자의 지속적인 refresh로 Refresh Token rotation을 깨트려, 사용자의 인증 상태를 교란 시킬 수 있습니다.
- 사용자의 이전 Access Token이 만료되어 토큰 인증 만료 또는 강제 로그아웃 될 수 있습니다.
- 사용자의 계정이 정책에 의해 잠길 수 있습니다.
- 쿠키를 사용하는 다른 인증 기반이 CSRF 공격에 취약해집니다.
Spring Security의 CSRF 기본 설정
Spring Security는 SSR(서버 사이드 렌더링) 환경을 전제로 아래 흐름을 기본으로 사용합니다.
[ 기본 동작 흐름 ]
SSR(Spring MVC + Thymeleaf)에서의 CSRF 토큰 인증은 다음과 같이 진행됩니다.
1. 웹 요청 시 서버는 CSRF 토큰을 세션에 저장합니다.
2. 응답할 HTML의 <form>내부에 <input type="hidden">에 "_csrf"와 같은 속성으로 CSRF토큰을 랜더링하고, readonly Cookie인 JSESSIONID에 세션 ID를 담아 브라우저에 응답합니다.
3. 사용자의 form 기반의 웹 요청 시 CSRF 토큰을 본문(body)에 포함해 사용자 의도를 증빙할 수 있습니다.
4. 서버 세션의 세션ID와, 제출한 CSRF 토큰이 일치할 경우 CSRF 설정을 통과합니다.
따라서 SSR이 아닌 SPA라면 CSRF토큰을 받을 수 없으므로, 원천적으로 CSRF차단이 됩니다.

REST + JWT (Refresh Token Rotation)의 CSRF 설정
REST + JWT(Refresh Token Rotation) 인증 방식에서의 CSRF 설정을 위해서는 아래 설정이 필요합니다.
- CORS 허용 헤더 추가
- 세션 비활성화
- CSRF 토큰 전달 방식 변경
1. CORS 허용 헤더 추가
이전에 설정한 Cors 설정 구성에 "X-XSRF-TOKEN"헤더를 허용하도록 설정합니다.
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-XSRF-TOKEN"));
SecurityFilterChain에는 다음과 같이 설정합니다.
http
.cors(cors -> cors.configurationSource(corsConfigurationSource))
2. 세션 비활성화
세션은 사용하지 않으므로, 비활성화 합니다.
http
.cors(cors -> cors.configurationSource(corsConfigurationSource))
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
3. CSRF 토큰 전달 방식 변경
우선 CSRF 토큰 전달 방식을 아래와 같이 설정해보겠습니다.
서버가 XSRF-TOKEN라는 쿠키로 CSRF 토큰을 담아서 줄 수 있도록 아래와 같이 변경합니다.
CSRF 토큰 저장소(csrfTokenRepository)가 CSRF 토큰을 httpOnly = false인 쿠키로 넘겨주도록 설정(CookieCsrfTokenRepository.withHttpOnlyFalse())합니다.
http
.cors(cors -> cors.configurationSource(corsConfigurationSource))
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
브라우저는 CSRF토큰을 쿠키로 받아, X-XSRF-TOKEN이라는 헤더에 담아서 요청의 의도를 증빙할 수 있습니다.

하지만 전달받은 쿠키로 전달받은 CSRF 토큰을 헤더에 담아서 전송해도 여전히 403 Forbidden이 발생합니다.

CSRF 핸들러 설정: CsrfTokenRequestHandler
Spring Security 6 부터는 기본 CsrfTokenRequestHandler가 XorCsrfTokenRequestAttributeHandle로 변경되었습니다.
XorCsrfTokenRequestAttributeHandler는 SSR 환경을 위해 설계된 메커니즘으로, HTML 렌더링 시 CSRF 토큰을 XOR 마스킹(masking)하여 노출하고, 클라이언트가 이를 전달하면 서버가 다시 원본(raw) 토큰으로 복원한 뒤 검증하는 방식으로 동작합니다.
이를 통해 CSRF 토큰을 그대로 HTML에 노출할 경우 발생할 수 있는 BREACH 공격과 같은 응답 기반 사이드채널 취약점을 줄이기 위한 SSR 전용 보안 강화 메커니즘 입니다.
[ SPA 방식에서 사용되지 않는 이유 ]
SPA(React, Vue 등) 환경에서는 서버가 HTML을 직접 렌더링하지 않기 때문에 XorCsrfTokenRequestAttributeHandler의 장점을 활용할 구조가 존재하지 않습니다.
SPA에서는 CSRF 토큰을 일반적으로 다음과 같은 방식으로 처리합니다.
- 서버가 XSRF-TOKEN 쿠키로 raw CSRF 토큰을 내려주고
- 프론트가 해당 쿠키를 읽어서
- X-XSRF-TOKEN 헤더로 그대로 서버에 전송합니다.
아래와 같이 XorCsrfTokenRequestAttributeHandler 대신, CsrfTokenRequestAttributeHander를 주입해 설정할 수 있습니다.
CsrfTokenRequestAttributeHandler requestHandler =
new CsrfTokenRequestAttributeHandler();
CookieCsrfTokenRepository cookieCsrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();
cookieCsrfTokenRepository.setCookieCustomizer(builder -> builder
.sameSite("None")
.secure(true)
);
http
.cors(cors -> cors.configurationSource(corsConfigurationSource))
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.csrf(csrf -> csrf
.csrfTokenRepository(cookieCsrfTokenRepository)
.csrfTokenRequestHandler(requestHandler)
);
이를 통해 헤더에 포함된 raw CSRF 토큰에 대해 서버가 정상적으로 요청을 수행할 수 있습니다.

또는 SPA 방식에 필요한 기본 설정만을 제공하는 방식으로 다음과 같이 간단하게 설정할 수 있습니다.
전역적으로 CSRF 보호를 활성화하며, 핸들러를 명시적으로 선언하지 않아도 됩니다.
http
.cors(cors -> cors.configurationSource(corsConfigurationSource))
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.csrf(CsrfConfigurer::spa)
회고
"REST + JWT(Access/Refresh) 구조에서 세션을 사용하지 않는데도 왜 Refresh Token에 CSRF 보호가 필요할까?" 이 질문에서 이번 학습이 시작됐다. 처음에는 Refresh Token이 단지 Access Token 재발급에만 쓰이고, 응답도 결국 피해자 브라우저로 전달되니 CSRF 위험이 크지 않아 보였다. 하지만 전체 흐름을 정리해보니, Refresh Token이 쿠키 기반으로 전송되는 순간 서버의 상태를 공격자가 의도한 대로 변경할 수 있다는 점이 가장 큰 문제라는 것을 깨달았다.
특히 공격자가 CSRF를 이용해 강제 refresh 요청을 반복하면,
- Refresh Token Rotation이 망가지거나
- 사용자가 비정상적으로 로그아웃되거나
- 전체 인증 흐름이 꼬이는 문제
처럼 직접적인 탈취가 없더라도 서버의 인증 상태가 오염되는 위험이 충분히 존재했다.
또한 학습 과정에서 자연스럽게 Spring Security의 CSRF 기본 설정도 알아보게 되었다. Spring Security는 원래 SSR + 세션 기반을 전제로 설계되었기 때문에, XorCsrfTokenRequestAttributeHandler 같은 기본값이 HTML 렌더링과 BREACH 방어를 중심으로 구성되어 있다는 점을 이해할 수 있었다.
처음에는 “왜 이렇게 복잡한 기본값이 있는지” 혼란스러웠지만, 공식 문서를 찾아보며 SSR 환경에서 왜 XOR 마스킹이 필요한지, 그리고 SPA + REST 환경에서는 왜 이를 비활성화하고 raw CSRF Token을 헤더로 보내는 방식으로 설정을 바꿔야 하는지 명확하게 알게 되었다. 이 과정에서 “기본 설정의 의도”와 “내가 사용하는 아키텍처에 맞는 설정”을 구분할 줄 아는 감각도 길러졌다.
추가로 XSS 관점에서는 Refresh Token은 XSS로부터 보호하기 위해 HttpOnly 쿠키로 발급했지만, 반대로 CSRF Token은 SPA가 읽어야 하기 때문에 HttpOnly = false로만 제공할 수 있다. 처음엔 이게 모순처럼 느껴졌지만, CSRF와 XSS는 보호하려는 공격 모델 자체가 완전히 다르기 때문에 각각 독립적으로 대응해야 한다는 사실을 이해할 수 있었다.
이번 과정을 통해
- CSRF가 정확히 어떤 공격을 방어하는지
- Refresh Token 기반 인증에서 왜 필수인지
- Spring Security의 기본 설정이 어떤 배경에서 만들어졌는지
- SPA 환경에서는 왜 CSRF 설정을 재구성해야 하는지
- 그리고 CSRF와 XSS가 어떻게 다른 공격인
를 훨씬 구체적으로 정리할 수 있었다.
다음에는 자연스럽게 이어지는 주제인 XSS를 방어하는 방법에 대해 공부해보면 좋을 것 같다.
참고
https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html
Cross Site Request Forgery (CSRF) :: Spring Security
To handle an AccessDeniedException such as InvalidCsrfTokenException, you can configure Spring Security to handle these exceptions in any way you like. For example, you can configure a custom access denied page using the following configuration: Configure
docs.spring.io
XorCsrfTokenRequestAttributeHandler (spring-security-docs 7.0.0 API)
All MethodsInstance MethodsConcrete Methods void handle(jakarta.servlet.http.HttpServletRequest request, jakarta.servlet.http.HttpServletResponse response, Supplier deferredCsrfToken) Returns the token value resolved from the provided HttpServletReques
docs.spring.io