사용자가 은행 사이트에 로그인한 상태에서 악성 사이트를 열었는데, 본인도 모르게 송금이 실행됩니다. 어떻게 이런 일이 가능할까요?

이것이 가능한 이유는 브라우저가 쿠키를 요청 대상 도메인 기준으로 자동 전송 하기 때문입니다. CSRF(Cross-Site Request Forgery) 는 이 특성을 악용해 사용자가 의도하지 않은 요청을 대신 보내게 만드는 공격이며, 스프링 시큐리티는 CSRF 토큰으로 이를 방어합니다.

CSRF(Cross-Site Request Forgery)란

CSRF 는 로그인으로 만들어진 세션 쿠키를 악용하여, 사용자가 의도하지 않은 요청을 대신 보내게 만드는 공격입니다. 브라우저가 해당 도메인의 쿠키를 요청마다 자동으로 전송하는 특성을 이용합니다.


세션·쿠키는 어떻게 만들어지는가

서블릿 기반 웹 애플리케이션에서는 HttpServletRequest.getSession()이 호출되는 시점에 서블릿 컨테이너가 세션을 생성합니다.

JAVA
@PostMapping
public ResponseEntity<String> handleLoginRequest(HttpServletRequest request) {
    HttpSession session = request.getSession();
    session.setAttribute("username", "SIM JUNGHUN");
    return ResponseEntity.ok("ok");
}

위 코드에서 getSession()이 호출되면 톰캣이 세션 ID(JSESSIONID)를 생성하고, Set-Cookie 헤더로 브라우저에 내려보냅니다. 스프링 시큐리티도 동일한 메커니즘을 사용하여 SecurityContextPersistenceFilter 안에서 세션을 준비하고, SPRING_SECURITY_CONTEXT 키로 인증 정보를 보관합니다.

한 번 발급된 JSESSIONID 쿠키는 Domain, Path, Secure, SameSite 속성 조건을 만족하는 한 브라우저가 이후 요청마다 자동으로 전송 합니다.

속성전송 규칙
Domain요청 호스트가 Domain과 일치할 때 전송
Path요청 경로가 Path로 시작할 때 전송
SecureHTTPS 같은 보안 채널에서만 전송
SameSitesame-site/cross-site 여부에 따라 전송 제한

CSRF 공격이 가능한 이유

공격자는 이 쿠키 자동 전송 특성을 그대로 활용합니다.

  1. 사용자의 브라우저에는 이미 bank.com의 세션 쿠키가 있다.
  2. 브라우저는 bank.com으로 향하는 모든 요청에 이 쿠키를 자동으로 붙인다.
  3. 악성 사이트(devil.com)가 bank.com으로 향하는 폼을 몰래 만들어 자동 전송한다.
HTML
<form action="https://bank.com/transfer" method="POST">
  <input type="hidden" name="to" value="attacker-account" />
  <input type="hidden" name="amount" value="1000000" />
</form>
<script>document.forms[0].submit();</script>

브라우저 입장에서 요청 대상이 bank.com이므로 쿠키를 자동으로 포함시킵니다. 서버는 이 요청이 공격자가 보냈는지, 실제 사용자가 보냈는지를 구분할 수 없습니다.

이 빈틈을 막기 위해 "이 요청이 우리 화면에서 사용자가 직접 보낸 것인지"를 한 번 더 확인해 주는 CSRF 토큰이 필요합니다.


스프링 시큐리티의 CSRF 기본 동작

이 섹션은 폼 기반 MVC 웹 앱(Thymeleaf, JSP)에서 http.csrf() 기본 설정을 사용하는 경우를 전제로 합니다.

CSRF 아키텍처

spring-security-csrf-architecture

SecurityFilterChain에 등록된 CsrfFilter 가 요청의 HTTP 메서드에 따라 다른 방식으로 동작합니다.

CsrfFilter 내부의 DefaultRequiresCsrfMatcher가 Safe/Unsafe를 판단합니다.

JAVA
private static final class DefaultRequiresCsrfMatcher implements RequestMatcher {
    private final HashSet<String> allowedMethods =
        new HashSet<>(Arrays.asList("GET", "HEAD", "TRACE", "OPTIONS"));

    @Override
    public boolean matches(HttpServletRequest request) {
        return !this.allowedMethods.contains(request.getMethod());
    }
}

이 코드의 핵심은 GET, HEAD, OPTIONS, TRACE는 상태를 변경하지 않는다고 가정하여 CSRF 토큰 검사를 건너뛴다 는 점입니다. POST, PUT, PATCH, DELETE 같은 Unsafe 메서드만 토큰을 검증합니다.

CSRF 토큰은 언제 만들어지는가

Spring Security 6에서는 필요할 때까지 토큰 생성을 미루는(deferred) 방식 을 사용합니다. 뷰 렌더링 과정에서 _csrf 속성에 실제로 접근하는 시점에 토큰이 생성됩니다.

사용자가 GET으로 페이지를 처음 요청하면, CsrfFilter가 CsrfTokenRepository를 통해 토큰을 준비하고 HttpServletRequest_csrf 속성에 넣습니다. Thymeleaf 같은 렌더링 엔진이 이 값을 꺼내 hidden 필드로 렌더링합니다.

HTML
<form action="https://bank.com/transfer" method="post">
    <label>받는 사람 계좌: <input type="text" name="to"/></label>
    <label>금액: <input type="number" name="amount"/></label>
    <input type="hidden"
           name="${_csrf.parameterName}"
           value="${_csrf.token}"/>
    <input type="submit" value="송금하기"/>
</form>

이 hidden 필드는 bank.com이 렌더링한 HTML 안에서만 올바른 값으로 채워집니다. 외부 사이트에서는 이 값을 알 수 없습니다.

어떻게 CSRF 토큰을 검증하는가

CSRF 토큰 검증은 CsrfFilter에서 4단계로 이루어집니다.

1단계 — CSRF 검사 대상인지 확인. HTTP 메서드가 Unsafe(POST, PUT, PATCH, DELETE)인 경우만 검사합니다.

2단계 — 서버에 저장된 CSRF 토큰 로드. CsrfTokenRepository에서 이 세션에 저장된 토큰을 조회합니다.

JAVA
DeferredCsrfToken deferredCsrfToken = this.tokenRepository.loadDeferredToken(request, response);
CsrfToken csrfToken = deferredCsrfToken.get();

3단계 — 클라이언트가 보낸 토큰 추출. 폼 필드(_csrf 파라미터)나 헤더(X-CSRF-TOKEN)에서 값을 읽어옵니다.

JAVA
String actualToken = this.requestHandler.resolveCsrfTokenValue(request, csrfToken);

4단계 — 두 토큰 비교. 일치하면 필터 체인을 계속 진행하고, 불일치하면 AccessDeniedException을 발생시켜 403을 반환합니다.

JAVA
if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {
    this.accessDeniedHandler.handle(request, response, exception);
    return;
}
filterChain.doFilter(request, response);

equalsConstantTime을 사용하는 이유는, 문자열 비교 시간 차이로 토큰 값을 유추하는 ** 타이밍 공격 **을 방지하기 위해서입니다.

devil.com이 이 토큰을 맞출 수 없는 이유

CSRF 토큰은 bank.com이 렌더링한 HTML 안에만 존재합니다. devil.com은 세션 쿠키(JSESSIONID)에 의존해 bank.com으로 POST 요청을 보낼 수는 있지만, ** 현재 세션의 CSRF 토큰 값을 알 수 없습니다.** 결국 토큰이 없거나 틀렸기 때문에 CsrfFilter에서 403으로 차단됩니다.

(참고) 최근 브라우저는 기본 SameSite=Lax 정책으로 일부 cross-site 요청의 쿠키 전송 자체를 제한합니다. 하지만 SameSite만으로 모든 케이스를 방어할 수는 없으며, 레거시 브라우저도 고려해야 하기에 CSRF 토큰 검증은 여전히 필요합니다.


REST API·SPA에서의 CSRF 처리

지금까지 살펴본 동작은 HttpSessionCsrfTokenRepository를 사용하는 서버 세션 기반(MVC + 폼) 방식 입니다.

SPA에서 JWT를 Authorization: Bearer ... 헤더로 직접 보내는 구조라면, 브라우저가 자격 증명을 자동 전송하지 않기 때문에 CSRF 공격이 성립하기 어렵습니다. 이 경우 http.csrf().disable()을 고려할 수 있습니다.

하지만 SPA에서 세션 쿠키나 HttpOnly 액세스 토큰 쿠키 처럼 브라우저가 자동 전송하는 쿠키 기반 인증을 사용한다면, CSRF 보호가 여전히 필요합니다.

이때는 CookieCsrfTokenRepository를 사용합니다.

JAVA
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http.csrf(csrf -> csrf
        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
    );
    return http.build();
}

이 설정으로 바꾸면 CSRF 토큰이 세션 대신 쿠키(XSRF-TOKEN)에 저장되고, 프론트엔드가 이 쿠키 값을 읽어 X-XSRF-TOKEN 헤더로 보내는 방식으로 동작합니다. "서버 토큰 vs 클라이언트 토큰을 비교한다" 는 큰 구조는 동일합니다.


주의할 점

REST API에서 CSRF를 무조건 끄면 안 되는 경우가 있다

JWT 기반이면 CSRF가 불필요하지만, 쿠키 기반 인증(세션)을 사용하는 SPA 에서는 CSRF 보호가 필요합니다. 인증 방식을 먼저 확인하고 판단해야 합니다.

CSRF 토큰이 세션과 불일치하면 403이 나온다

세션이 만료되거나 새로고침 없이 오래 방치된 페이지에서 폼을 제출하면, CSRF 토큰과 세션이 불일치하여 403이 발생합니다. 사용자에게 "세션이 만료되었습니다" 안내와 함께 재로그인을 유도해야 합니다.


정리

항목설명
CSRF쿠키 자동 전송을 악용한 요청 위조 공격
방어 원리서버가 발급한 토큰을 요청에 포함시켜 "우리 화면에서 보낸 요청"인지 검증
검사 대상POST, PUT, PATCH, DELETE (Unsafe 메서드만)
MVC 폼HttpSessionCsrfTokenRepository — hidden 필드로 토큰 전송
SPA + 쿠키 인증CookieCsrfTokenRepository — 쿠키/헤더로 토큰 전송
SPA + JWT 헤더csrf().disable() 고려 가능
타이밍 공격 방지equalsConstantTime으로 토큰 비교
댓글 로딩 중...