분명 설정은 맞는 것 같은데 403이 나옵니다. 어디서부터 봐야 할까요?

스프링 시큐리티는 제대로 동작할 때는 편하지만, 문제가 생기면 어디서 막히는지 찾기 어렵습니다. 필터 체인이 요청을 가로채는 구조라서, 에러 메시지가 모호하고 디버깅 포인트가 분산되어 있기 때문입니다. 이 글에서는 실무에서 가장 자주 겪는 시큐리티 버그 7가지와 각각의 해결법을 정리합니다.

1. POST 요청이 403인데 원인을 모르겠다 — CSRF

**증상 **: GET은 되는데 POST/PUT/DELETE가 전부 403.

** 원인 **: 스프링 시큐리티는 기본적으로 CSRF 보호가 ** 켜져 있습니다 **. POST 요청에 CSRF 토큰이 없으면 CsrfFilter가 403을 반환합니다.

** 해결 **:

JAVA
// REST API 서버라면 CSRF 비활성화 (세션 대신 토큰 인증 사용 시)
http.csrf(csrf -> csrf.disable());

// 폼 기반이라면 CSRF 토큰을 포함
// Thymeleaf: 자동 포함됨
// 수동: <input type="hidden" name="_csrf" th:value="${_csrf.token}"/>

CSRF를 끄기 전에 "왜 끄는지" 판단해야 합니다. 세션 + 쿠키 기반 인증이면 CSRF 보호가 필요합니다. JWT 토큰 기반이면 CSRF가 불필요합니다.

2. CORS 에러 — 프론트엔드에서 API 호출이 안 된다

** 증상 **: 브라우저 콘솔에 Access-Control-Allow-Origin 에러.

** 원인 : 스프링 시큐리티의 CorsFilter가 Spring MVC의 @CrossOrigin보다 ** 먼저 실행됩니다. 시큐리티에서 CORS를 설정하지 않으면 프리플라이트(OPTIONS) 요청이 거부됩니다.

** 해결 **:

JAVA
http.cors(cors -> cors.configurationSource(request -> {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("http://localhost:3000"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
    config.setAllowedHeaders(List.of("*"));
    config.setAllowCredentials(true);
    return config;
}));

@CrossOrigin만으로는 안 됩니다. 시큐리티의 CORS 설정이 우선합니다.

3. 커스텀 필터가 두 번 실행된다

**증상 **: JWT 필터의 로그가 요청마다 2번 출력됨.

** 원인 **: 필터를 @Component로 등록하면 스프링이 서블릿 필터로 자동 등록하고, addFilterBefore()로 시큐리티 체인에도 등록해서 ** 두 곳에서 실행 **됩니다.

** 해결 **:

JAVA
// ❌ @Component 제거
// @Component  ← 이거 빼기
public class JwtFilter extends OncePerRequestFilter { ... }

// ✅ 시큐리티 설정에서만 등록
http.addFilterBefore(new JwtFilter(jwtProvider),
    UsernamePasswordAuthenticationFilter.class);

4. 로그인 성공했는데 다음 요청에서 인증이 풀린다

** 증상 **: 로그인 API는 200인데, 이후 요청에서 401/403.

** 원인 후보 3가지 **:

원인확인 방법
세션이 생성 안 됨SessionCreationPolicy.STATELESS 확인
JSESSIONID 쿠키가 안 날아감브라우저 개발자 도구 → 쿠키 탭
SecurityContext가 저장 안 됨SecurityContextRepository 설정 확인

JWT 기반이면 매 요청마다 토큰을 헤더에 포함해야 합니다. 세션 기반이면 JSESSIONID 쿠키가 올바르게 설정되어 있는지 확인합니다.

5. hasRole('ADMIN')이 안 먹힌다

** 증상 **: DB에 권한이 ADMIN으로 저장되어 있는데 hasRole('ADMIN')이 거부됨.

** 원인 **: hasRole()은 자동으로 ROLE_ 접두사를 붙입니다. DB에 ADMIN으로 저장했으면 실제로는 ROLE_ADMIN을 찾습니다.

** 해결 **:

JAVA
// 방법 1: DB에 ROLE_ 접두사 포함해서 저장
authorities = List.of(new SimpleGrantedAuthority("ROLE_ADMIN"));

// 방법 2: hasAuthority() 사용 (접두사 안 붙음)
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/admin/**").hasAuthority("ADMIN")
);

6. 테스트에서 인증이 안 된다

** 증상 **: @WebMvcTest에서 API 호출하면 무조건 403.

** 원인 **: @WebMvcTest는 시큐리티 필터를 포함합니다. 테스트에 인증 정보를 넣지 않으면 모든 요청이 거부됩니다.

** 해결 **:

JAVA
// 방법 1: @WithMockUser
@Test
@WithMockUser(username = "user", roles = {"USER"})
void 인증된_사용자_테스트() { ... }

// 방법 2: MockMvc에 인증 정보 추가
mockMvc.perform(get("/api/profile")
    .with(SecurityMockMvcRequestPostProcessors.user("user")))
    .andExpect(status().isOk());

// 방법 3: 시큐리티 비활성화 (비추천 — 보안 테스트를 포기하는 것)
@WebMvcTest(excludeAutoConfiguration = SecurityAutoConfiguration.class)

7. 디버깅 — 필터 체인에서 뭐가 일어나는지 보는 법

문제가 생겼을 때 가장 먼저 할 일은 ** 시큐리티 디버그 로그를 켜는 것 **입니다.

YAML
# application.yml
logging:
  level:
    org.springframework.security: DEBUG

이 한 줄이면 어떤 필터가 실행되었는지, 인증이 어디서 성공/실패했는지, SecurityContext에 뭐가 들어있는지 전부 보입니다.

PLAINTEXT
DEBUG FilterChainProxy - Securing POST /api/orders
DEBUG CsrfFilter - Invalid CSRF token found for POST /api/orders
DEBUG FilterChainProxy - REJECTED

시큐리티 문제가 생기면 코드를 보기 전에 ** 디버그 로그부터 켜세요.** 90%는 로그만 보면 원인이 보입니다.

정리

증상원인해결
POST만 403CSRF 토큰 누락csrf.disable() 또는 토큰 포함
CORS 에러시큐리티 CORS 미설정http.cors() 설정
필터 2번 실행@Component + addFilter 중복@Component 제거
로그인 후 인증 풀림세션/토큰 미전달쿠키 또는 헤더 확인
hasRole 안 먹힘ROLE_ 접두사hasAuthority() 또는 접두사 추가
테스트 403인증 정보 없음@WithMockUser
원인 모름security: DEBUG 로그
댓글 로딩 중...