주의
이 책은 Spring Security 5 버전을 기준으로 작성되었으므로, Spring Boot 3.X 버전에서는 일부 클래스가 더 이상 사용되지(deprecated) 않을 수 있습니다.
1. 애플리케이션에 CSRF (사이트 간 요청 위조) 보호 적용
- 스프링 시큐리티에 기본적으로 CSRF 보호가 활성화되어 있음
- CSRF는 광범위한 공격 유형이며 CSRF에 취약한 애플리케이션은 인증 후 사용자가 웹 애플리케이션에서 원치 않는 작업을 실행하게 할 수 있음
- CSRF에 취약한 애플리케이션을 개발해서 사용자가 원치 않는 작업이 실행되는 상황은 누구도 원하지 않을 것
1.1 스프링 시큐리티의 CSRF 보호가 작동하는 방식
- CSRF 공격은 사용자가 웹 애플리케이션에 로그인했다고 가정하며 사용자는 공격자에게 속아서 작업 중인 같은 애플리케이션에서 작업을 실행하는 스크립트가 포함된 페이지를 염
- 사용자가 이미 로그인했기 때문에 위조 코드는 이제 사용자를 가장하고 사용자 대신 작업을 수행할 수 있음
- CSRF 보호는 웹 애플리케이션에서 프런트 엔드만 변경 작업 (관례상 GET, HEAD, TRACE, OPTIONS 외의 HTTP 방식)을 수행할 수 있게 보장함
- 그러면 아래 예제와 같은 외부 페이지가 사용자 대신 작업을 수행할 수 없음

- 데이터를 변경하는 작업을 수행하기 위해서는 먼저 사용자가 적어도 한 번은 HTTP GET으로 웹 페이지를 요청해야 함
- 이때 애플리케이션은 고유한 토큰을 생성
- 이제부터 애플리케이션은 헤더에 이 고유한 값이 들어 있는 요청에 대해서만 변경 작업 (POST, PUT, DELETE 등)을 수행
- 애플리케이션이 토큰의 값을 안다는 것은 다른 시스템이 아닌 애플리케이션 자체가 변경 요청을 보낸 증거라고 봄
- POST, PUT, DELETE를 비롯한 변경 호출을 포함하는 모든 페이지는 응답을 통해 CSRF 토큰을 받고 변경 호출을 할 때 해당 토큰을 이용해야 함
- CSRF 보호의 시작점은 필터 체인의 CsrfFilter라는 한 필터
- CsrfFilter는 요청을 가로채고 GET, HEAD, TRACE, OPTIONS를 포함하는 HTTP 방식의 요청을 모두 허용하고 다른 모든 요청에는 토큰이 포함된 헤더가 있는지 확인함
- 해당 헤더가 없거나 헤더에 잘못된 토큰 값이 포함된 경우 애플리케이션은 요청을 거부하고 응답의 상태를 `403 Forbidden`으로 설정
- 요청에 포함된 토큰은 하나의 문자열 값이며, GET, HEAD, TRACE, OPTIONS 외의 HTTP 방식을 이용할 때 요청의 헤더에 해당 토큰을 추가해야 함
- 토큰을 포함하는 헤더를 추가하지 않으면 애플리케이션은 아래 그림과 같이 요청을 수락하지 않음

- CsrfFilter는 CsrfTokenRepository 구성 요소를 이용해 새 토큰 생성, 토큰 저장 그리고 토큰 검증에 필요한 CSRF 토큰 값을 관리함
- 기본적으로 CsrfTokenRepository는 토큰을 HTTP 세션에 저장하고 랜덤 UUID로 토큰을 생성함
- 대부분은 이것으로 충분하지만 구현할 요구 사항이 기본 구현으로 해결되지 않을 경우 CsrfTokenRepository를 직접 구현하는 방법도 있음

- CsrfFilter는 생성된 CSRF 토큰을 HTTP 요청의 _csrf 특성에 추가하며 이것을 알면 CsrfFilter 뒤에서 해당 특성을 찾아 토큰 값을 가져올 수 있음
- 여기서 찾은 토큰 값을 CSRF 토큰을 지정한 뒤 HTTP POST 방식으로 엔드포인트를 호출하면 정상 응답을 받을 수 있음
- 또한 CsrfTokenRepository의 기본 구현은 CSRF 토큰의 값을 세션에 저장하므로 세션 ID(JSESSIONID)도 지정해야 함
- CSRF 토큰을 지정하지 않은 상태로 POST 방식으로 엔드포인트를 호출하면 403 Forbidden 응답을 받음
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class CsrfTokenLogger implements Filter { | |
private Logger logger = | |
Logger.getLogger(CsrfTokenLogger.class.getName()); | |
@Override | |
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException { | |
Object o = request.getAttribute("_csrf"); | |
CsrfToken token = (CsrfToken) o; | |
logger.info("CSRF token " + token.getToken()); | |
filterChain.doFilter(request, response); | |
} | |
} | |
@Configuration | |
public class ProjectConfig extends WebSecurityConfigurerAdapter { | |
@Override | |
protected void configure(HttpSecurity http) throws Exception { | |
http.addFilterAfter( | |
new CsrfTokenLogger(), | |
CsrfFilter.class) | |
.authorizeRequests() | |
.anyRequest().permitAll(); | |
} | |
} |

1.2 실제 시나리오에서 CSRF 보호 사용
- CSRF 보호는 브라우저에서 실행되는 웹 앱에 이용되며, 앱의 표시된 컨텐츠를 로드하는 브라우저가 변경 작업을 수행할 수 있다고 예상될 때 필요함
- 기본 로그인의 경우 스프링 시큐리티는 CSRF 보호를 올바르게 적용해 주고 프레임워크가 CSRF 토큰을 로그인 요청에 추가하는 작업을 처리해 줌
- 예제 애플리케이션에서는 CSRF 토큰을 올바르게 사용할 때까지 HTTP POST 호출이 작동하지 않는다는 것을 확인하고 웹 페이지의 양식에 CSRF 토큰을 적용하는 방법을 배울 수 있음

This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Configuration | |
public class ProjectConfig extends WebSecurityConfigurerAdapter { | |
@Bean | |
public UserDetailsService uds() { | |
var uds = new InMemoryUserDetailsManager(); | |
var u1 = User.withUsername("mary") | |
.password("12345") | |
.authorities("READ") | |
.build(); | |
uds.createUser(u1); | |
return uds; | |
} | |
@Bean | |
public PasswordEncoder passwordEncoder() { | |
return NoOpPasswordEncoder.getInstance(); | |
} | |
@Override | |
protected void configure(HttpSecurity http) throws Exception { | |
http.authorizeRequests() | |
.anyRequest().authenticated(); | |
http.formLogin() | |
.defaultSuccessUrl("/main", true); | |
} | |
} | |
@Controller | |
public class MainController { | |
@GetMapping("/main") | |
public String main() { | |
return "main.html"; | |
} | |
} | |
@Controller | |
@RequestMapping("/product") | |
public class ProductController { | |
private Logger logger = | |
Logger.getLogger(ProductController.class.getName()); | |
@PostMapping("/add") | |
public String add(@RequestParam String name) { | |
logger.info("Adding product " + name); | |
return "main.html"; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE HTML> | |
<html lang="en" xmlns:th="http://www.thymeleaf.org"> | |
<head> | |
</head> | |
<body> | |
<form action="/product/add" method="post"> | |
<span>Name:</span> | |
<span><input type="text" name="name" /></span> | |
<span><button type="submit">Add</button></span> | |
<-- hidden input으로 CSRF 토큰을 요청에 --> | |
<input type="hidden" | |
th:name="${_csrf.parameterName}" | |
th:value="${_csrf.token}" /> | |
</form> | |
</body> | |
</html> |
1.3 CSRF 보호 맞춤 구성
- CSRF 보호는 서버에서 생성된 리소스를 이용하는 페이지가 같은 서버에서 생성된 경우에만 이용
- 기본적으로 CSRF 보호는 GET, HEAD, TRACE, OPTIONS 외의 HTTP 방식으로 호출되는 엔드포인트의 모든 경로에 적용됨
- 아래 코드는 HTTP POST로 호출하는 엔드포인트 두 개를 추가하지만 둘 중 하나는 CSRF 보호에서 제외하는 예제
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Configuration | |
public class ProjectConfig extends WebSecurityConfigurerAdapter { | |
@Bean | |
public CsrfTokenRepository customTokenRepository() { | |
return new CustomCsrfTokenRepository(); | |
} | |
@Override | |
protected void configure(HttpSecurity http) throws Exception { | |
// 람다식의 매개변수는 CsrfConfigurer | |
// 해당 메서드를 호출해 다양한 방식으로 CSRF 보호를 구성할 수 있음 | |
// Customizer<CsrfConfigurer<HttpSecurity>> 객체를 이용해 새 CsrfTokenRepository 구현을 CSRF 보호 메커니즘에 | |
http.csrf(c -> { | |
c.csrfTokenRepository(customTokenRepository()); | |
c.ignoringAntMatchers("/ciao"); | |
}); | |
http.authorizeRequests() | |
.anyRequest().permitAll(); | |
} | |
} | |
// 스프링 시큐리티에서 CSRF 토큰을 관리하는 책임을 맡음 | |
public class CustomCsrfTokenRepository | |
implements CsrfTokenRepository { | |
@Autowired | |
private JpaTokenRepository jpaTokenRepository; | |
@Override | |
public CsrfToken generateToken(HttpServletRequest httpServletRequest) { | |
String uuid = UUID.randomUUID().toString(); | |
return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", uuid); | |
} | |
@Override | |
public void saveToken(CsrfToken csrfToken, HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) { | |
String identifier = httpServletRequest.getHeader("X-IDENTIFIER"); | |
Optional<Token> existingToken = jpaTokenRepository.findTokenByIdentifier(identifier); | |
if (existingToken.isPresent()) { | |
Token token = existingToken.get(); | |
token.setToken(csrfToken.getToken()); | |
} else { | |
Token token = new Token(); | |
token.setToken(csrfToken.getToken()); | |
token.setIdentifier(identifier); | |
jpaTokenRepository.save(token); | |
} | |
} | |
@Override | |
public CsrfToken loadToken(HttpServletRequest httpServletRequest) { | |
String identifier = httpServletRequest.getHeader("X-IDENTIFIER"); | |
Optional<Token> existingToken = jpaTokenRepository.findTokenByIdentifier(identifier); | |
if (existingToken.isPresent()) { | |
Token token = existingToken.get(); | |
return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", token.getToken()); | |
} | |
return null; | |
} | |
} | |
public interface JpaTokenRepository extends JpaRepository<Token, Integer> { | |
Optional<Token> findTokenByIdentifier(String identifier); | |
} | |
@Entity | |
public class Token { | |
@Id | |
@GeneratedValue(strategy = GenerationType.IDENTITY) | |
private int id; | |
private String identifier; | |
private String token; | |
public int getId() { | |
return id; | |
} | |
public void setId(int id) { | |
this.id = id; | |
} | |
public String getIdentifier() { | |
return identifier; | |
} | |
public void setIdentifier(String identifier) { | |
this.identifier = identifier; | |
} | |
public String getToken() { | |
return token; | |
} | |
public void setToken(String token) { | |
this.token = token; | |
} | |
} |



2. CORS (교차 출처 리소스 공유) 이용
- 기본적으로 브라우저는 사이트가 로드된 도메인 이외의 도메인에 대한 요청을 허용하지 않음
- i.g. example.com에서 사이트를 열었다면 브라우저는 해당 사이트에서 api.example.com에 요청하는 것을 허용하지 않음

- 간단히 말하자면 브라우저는 CORS 메커니즘으로 이 엄격한 정책을 완화하고 일부 조건에서 서로 다른 출처 간의 요청을 허용한다고 할 수 있음
- 특히 프런트엔드와 백엔드가 별도의 애플리케이션인 요즘에는 이를 애플리케이션에 적용해야 할 가능성이 크기 때문에 이를 알아야 함
- ex) 리액트 프레임워크로 개발하고 example.com 도메인에서 호스팅 하는 프런트엔드 애플리케이션이 api.example.com 등의 다른 도메인에서 호스팅하는 백엔드의 엔드포인트를 호출하는 것이 일반적
2.1 CORS 작동 방식
- 애플리케이션이 두 개의 서로 다른 도메인 간에 호출하는 것은 모두 금지되지만 그렇나 호출이 필요할 때가 있음
- 이때 CORS를 이용하면 애플리케이션이 요청을 허용할 도메인, 그리고 공유할 수 있는 세부 정보를 지정할 수 있음
- CORS 메커니즘은 HTTP 헤더를 기반으로 작동하며 가장 중요한 헤더는 다음과 같음
- Access-Control-Allow-Origin: 도메인의 리소스에 접근할 수 있는 외부 도메인을 지정
- Access-Control-Allow-Methods: 다른 도메인에 대해 접근을 허용하지만 특정 HTTP 방식만 허용하고 싶을 때 일부 HTTP 방식을 지정할 수 있음
- Access-Control-Allow-Headers: 특정 요청에 이용할 수 있는 헤더에 제한을 추가

- 애플리케이션은 요청하고 응답을 받을 때 여기에 서버가 수락하는 출처가 나열된 Access-Control-Allow-Origin 헤더가 있다고 예상하는데 스프링 시큐리티의 기본 동작과 같이 해당 헤더가 없으면 브라우저는 응답을 수락하지 않음
- CORS는 제한을 가하기보다 교차 도메인 호출의 엄격한 제약 조건을 완화하도록 도와주는 기능
- 그리고 제한이 적용돼도 일부 상황에서는 엔드포인트를 호출할 수 있음
- 종종 브라우저는 요청을 허용해야 하는지 테스트하기 위해 먼저 HTTP OPTIONS 방식으로 호출하는 경우가 있음
- 이 테스트 요청을 preflight 요청이라고 하며 해당 요청이 실패하면 브라우저는 원래 요청을 수락하지 않음
- 리액트와 같은 프레임워크로 클라이언트 쪽 앱을 개발했을 때도 이런 상황이 발생할 수 있음
2.2 @CrossOrigin 어노테이션으로 CORS 정책 적용
- 엔드포인트를 정의하는 메서드 바로 위에 @CrossOrigin 어노테이션을 배치하고 허용된 출처와 메서드를 이용해 구성할 수 있음
- @CrossOrigin 어노테이션의 장점은 각 엔드포인트에 맞게 손쉽게 CORS를 구성할 수 있다는 것
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@PostMapping("/test") | |
@ResponseBody | |
@CrossOrigin("http://localhost:8080") // localhost 출처에 대한 교차 출처 요청을 허용 | |
public String test() { | |
logger.info("Test method called"); | |
return "HELLO"; | |
} |

- @CrossOrigin의 값 매개변수는 여러 출처를 정의한느 배열을 받음
- i.g. @CrossOrigin({"example.com", "example.org"})와 같이 지정할 수 있음
- 또한 어노테이션의 allowedHeaders 특성과 method 특성으로 허용되는 헤더와 메서드를 설정할 수 있음
- 출처와 헤더에 asterisk (*)를 이용하면 모든 헤더나 출처를 지정할 수 있지만 해당 방식을 이용할 때는 주의하는 것이 좋음
- 허용하려는 출처와 헤더를 필터링하고 어떤 도메인이든 애플리케이션의 리소스에 접근하는 코드를 구현하는 것을 허용하지 않는 것을 권장
- 모든 출처를 허용하면 애플리케이션이 XSS 요청에 노출되고 결과적으로 DDOS 공격에 취약해질 수 있음
- 테스트와 운영에 같은 데이터 센터를 이용하는 잘못 정의된 인프라에서 애플리케이션이 실행되는 경우가 있기 때문에 테스트 환경에서도 모든 출처를 허용하지 않는 것이 바람직함
@CrossOrigin 단점
- @CrossOrigin으로 엔드포인트가 정의되는 위치에서 직접 규칙을 지정하면 규칙이 투명해지는 장점이 있지만, 코드가 장황해지고 많은 코드를 반복해야 할 수 있다는 단점도 있음
- 또한 개발자가 새로 구현한 엔드포인트에 어노테이션을 추가하는 것을 잊어버릴 위험도 존재
2.3 CorsConfigurer로 CORS 적용
- 앞서 알아본 것처럼 @CrossOrigin 어노테이션은 쉽게 이용할 수 있지만 CORS 구성을 한 곳에서 정의하는 것이 더 편할 때가 있음
- 아래 코드를 통해 허용할 출처를 정의하기 위해 구성 클래스를 어떻게 변경해야 하는지 알 수 있음
- 아래 예제에서는 설명을 간단하게 하기 위해 configure() 메서드에서 람다 식으로 곧바로 CorsConfigurationSource의 구현을 제공했지만 실제 애플리케이션에서는 해당 코드를 다른 클래스로 나누는 것을 권장
- 실제 애플리케이션의 코드는 훨씬 복잡할 수 있으므로 구성 클래스로 나누지 않으면 코드가 읽기 어려워질 수 있음
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Configuration | |
public class ProjectConfig extends WebSecurityConfigurerAdapter { | |
@Override | |
protected void configure(HttpSecurity http) throws Exception { | |
// cors()를 호출해 CORS 구성을 정의 | |
// 여기에서 허용되는 출처와 메서드를 설정하는 CorsConfiguration 객체를 생성 | |
// CorsConfiguration 객체가 기본적으로 아무 메서드도 정의하지 않기 때문에 출처만 지정할 경우 애플리케이션이 요청을 허용하지 않음 | |
// 따라서 최소한 허용할 출처와 메서드를 지정해야 함 | |
http.cors(c -> { | |
CorsConfigurationSource source = request -> { | |
CorsConfiguration config = new CorsConfiguration(); | |
config.setAllowedOrigins(List.of("*")); | |
config.setAllowedMethods(List.of("*")); | |
return config; | |
}; | |
c.configurationSource(source); | |
}); | |
http.csrf().disable(); | |
http.authorizeRequests() | |
.anyRequest().permitAll(); | |
} | |
} |
참고
스프링 시큐리티 인 액션
반응형
'Spring > 스프링 시큐리티 인 액션' 카테고리의 다른 글
[13장] OAuth 2: 권한 부여 서버 구현 (0) | 2025.06.01 |
---|---|
[12장] OAuth 2가 작동하는 방법 (0) | 2025.05.31 |
[8장] 권한 부여 구성: 제한 적용 (0) | 2025.05.30 |
[7장] 권한 부여 구성: 액세스 제한 (0) | 2025.05.30 |
[5장] 인증 구현 (0) | 2025.05.21 |