이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 인증, 인가, 브라우저 보안, API 보안은 서로 어떤 관계인가?
  • Session, Cookie, JWT, CORS, CSRF는 어떤 공격을 각각 줄이는가?
  • Spring 백엔드에서는 어느 계층에서 어떤 보안 결정을 내려야 하는가?

개요

인증, 인가, 브라우저 보안, API 보안은 따로 떨어진 주제가 아니다. 사용자가 로그인하면 인증 결과로 principal이 생긴다. API는 principal을 기준으로 resource 접근을 판단한다. 브라우저는 쿠키를 자동으로 보내고 Origin 정책을 적용한다. 서버는 이 동작을 전제로 CSRF, CORS, SameSite, CSP, token 저장 방식을 정해야 한다.

이 연결을 놓치면 흔한 실수가 생긴다. “JWT를 쓰니까 CSRF는 무조건 필요 없다”, “CORS를 열었으니 인증 문제도 해결됐다”, “로그인했으니 주문 조회 권한이 있다” 같은 판단이다. 각 장치는 막는 공격이 다르며 서로 대체재가 아니다.

이 문서는 전체 지도의 연결부다. 이후 인증, token, 인가, 브라우저 보안, API 입력 검증 문서를 읽을 때 같은 요청 안에서 어떤 보안 결정이 이어지는지 떠올릴 수 있어야 한다.

공격 시나리오

공격자는 보안 장치 사이의 빈틈을 노린다.

  • 사용자는 정상 로그인했고 서버는 세션 쿠키를 발급했다.
  • 공격자는 다른 Origin의 페이지에서 피해자의 브라우저로 상태 변경 요청을 보낸다.
  • 서버는 쿠키가 있으니 인증된 요청으로 보고, CSRF token은 확인하지 않는다.
  • Service는 로그인 여부만 보고 orderId의 소유권은 확인하지 않는다.
  • 결과적으로 인증, 브라우저, 인가 경계가 각각 조금씩 비어 공격이 성공한다.

이런 사고는 “인증 실패”도 아니고 “CORS 오류”도 아니다. 여러 경계가 연결되지 않은 설계 문제다.

취약한 요청/응답 예시

POST /api/orders/991/cancel HTTP/1.1
Host: api.example.com
Cookie: SESSION=victim-session
Origin: https://attacker.example
Content-Type: application/json
 
{"reason":"changed mind"}
HTTP/1.1 200 OK
Content-Type: application/json
 
{"orderId":991,"status":"CANCELED"}

쿠키 기반 인증을 쓰면서 CSRF token과 Origin 검증이 없고, Service에서 주문 소유권도 확인하지 않은 예시다.

주제별 핵심 판단

  • 인증은 principal을 만들고 인가는 principal과 resource의 관계를 판단한다.
  • 브라우저 보안은 쿠키 자동 전송과 Origin 모델 때문에 API 보안과 분리할 수 없다.
  • JWT, Session, CSRF, CORS는 서로 다른 공격을 줄이며 서로 대체재가 아니다.
  • API 보안은 HTTP 경계와 Service 권한 경계가 모두 맞을 때 의미가 있다.
  • 보안 설정의 정답은 인증 정보가 어디에 저장되고 어떻게 전송되는지에 따라 달라진다.

개선된 요청/응답 예시

POST /api/orders/991/cancel HTTP/1.1
Host: api.example.com
Cookie: SESSION=victim-session; SameSite=Lax
Origin: https://app.example.com
X-CSRF-TOKEN: csrf-token-issued-by-server
Content-Type: application/json
 
{"reason":"changed mind"}
HTTP/1.1 403 Forbidden
Content-Type: application/json
 
{"code":"ORDER_ACCESS_DENIED","traceId":"9d2f"}

요청은 브라우저 경계와 업무 권한 경계를 모두 통과해야 한다. Origin과 CSRF token이 맞아도 주문 소유권이 다르면 거부되어야 한다.

방어 설계

  • 인증 방식이 쿠키 기반인지 Authorization header 기반인지 먼저 구분한다.
  • 쿠키 기반 상태 변경 요청에는 CSRF 방어와 SameSite 정책을 검토한다.
  • CORS는 허용 Origin의 응답 읽기 정책이지 서버 권한 검사가 아니다.
  • JWT는 서명, issuer, audience, expiry를 검증해야 하며 payload를 읽는 것만으로 인증하면 안 된다.
  • Controller는 요청 형식을 검증하고, Service는 principal과 resource의 관계를 판단한다.
  • Repository 조회에는 소유자 조건을 넣어 권한 누락을 줄인다.
  • 브라우저 보안 실패와 권한 실패는 서로 다른 이벤트로 남긴다.

Spring/HTTP 예시

@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
    return http
        .cors(Customizer.withDefaults())
        .csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
            .anyRequest().authenticated())
        .build();
}
 
@PreAuthorize("@orderPermission.canCancel(authentication, #orderId)")
@PostMapping("/api/orders/{orderId}/cancel")
public void cancel(@PathVariable Long orderId, @RequestBody CancelOrderRequest request) {
    orderService.cancel(orderId, request.reason());
}

CSRF 설정은 브라우저가 쿠키를 자동 전송하는 공격을 줄인다. @PreAuthorize는 인증된 사용자가 해당 주문을 취소할 권한이 있는지 별도로 확인한다. 둘 중 하나만으로는 충분하지 않다.

운영 로그와 감사 지점

level=WARN event=csrf_validation_failed origin=https://attacker.example path=/api/orders/991/cancel result=blocked traceId=9d2f
level=WARN event=authorization_denied actor=member:1004 resource=order:991 action=cancel result=blocked reason=owner_mismatch traceId=9d2f

브라우저 정책 실패와 권한 실패는 다른 원인이다. 로그 이벤트도 분리해야 운영자가 CORS 설정 문제인지 계정 탈취 의심인지 판단할 수 있다.

  • CSRF 실패에는 Origin, method, path를 남긴다.
  • 권한 실패에는 actor, resource, action, reason을 남긴다.
  • JWT 실패에는 issuer, audience, expiry, signature failure를 구분한다.
  • CORS 실패는 프론트 오류로만 보지 말고 허용 Origin 변경 이력과 연결한다.
  • 민감 token 원문은 어떤 로그에도 남기지 않는다.

실전 판단 기준

  • 인증 성공을 권한 성공으로 착각하지 않아야 한다.
  • CORS, CSRF, JWT, Session이 각각 막는 공격을 한 문장으로 설명할 수 있어야 한다.
  • 보안 설정은 저장 위치와 전송 방식 기준으로 리뷰한다.
  • 브라우저 정책과 Service 권한 정책이 모두 테스트되는지 확인한다.
  • 좋은 판단은 한 방어선이 깨졌을 때 다음 방어선이 피해를 줄이는지 확인하는 데 있다.

테스트 포인트

  • 쿠키 기반 상태 변경 요청에서 CSRF token이 없으면 거부되는지 확인한다.
  • 잘못된 Origin에서 credential 포함 요청을 보냈을 때 응답을 읽을 수 없는지 확인한다.
  • 유효한 로그인 세션으로 다른 사용자의 resource를 변경할 수 없는지 확인한다.
  • 만료 JWT, 잘못된 audience JWT, 서명 실패 JWT를 구분해 테스트한다.
  • 브라우저 정책 실패와 권한 실패가 서로 다른 로그 이벤트로 남는지 확인한다.

위험 신호!

  • “JWT를 쓰니까 CSRF는 신경 쓰지 않아도 된다”고 인증 저장 위치를 확인하지 않는다.
  • CORS 허용을 인증이나 권한 검사로 오해한다.
  • 로그인한 사용자면 모든 /api/me/** 요청이 안전하다고 가정한다.
  • CSRF를 끄면서 쿠키 기반 인증을 유지한다.
  • 권한 실패와 브라우저 정책 실패가 모두 같은 403 로그로만 남는다.

확인 질문

확인 질문

  • 인증과 인가의 차이는 무엇인가?
    • 인증은 사용자의 신원을 확인하는 절차이고, 인가는 특정 resource와 action에 대한 허용 여부를 판단하는 절차다.
  • CORS와 CSRF는 왜 서로 대체할 수 없는가?
    • CORS는 브라우저의 응답 읽기 정책이고, CSRF는 쿠키 자동 전송을 악용한 상태 변경 공격 방어이기 때문이다.
  • Spring Security 설정 이후에도 Service 권한 검사가 필요한 이유는 무엇인가?
    • 요청 수준 인증과 리소스 소유권 판단은 다른 문제이며, 업무 권한은 Service에서 다시 확인해야 하기 때문이다.

참고 문서