Server#BE#Spring Boot#로그인

OAuth2 로그인 토큰, 쿼리스트링에서 안전한 쿠키로 옮기기까지

JWT를 쿼리스트링으로 넘기던 로그인을 HttpOnly 쿠키로 바꾸고, Secure/SameSite 정책을 정하고, CSRF 방어까지 붙이기까지 3단계 보안 강화 기록

2026년 7월 11일3분 읽기조회 1
공유

들어가며

소셜 로그인 성공 후 프론트에 JWT를 어떻게 전달할지는 별생각 없이 넘어가기 쉬운 부분이지만, 방식에 따라 보안 수준이 크게 달라집니다. PLIMAP 백엔드에서는 쿼리스트링 전달 → HttpOnly 쿠키 전환 → 환경별 Secure/SameSite 정책 → CSRF 방어까지, 세 단계에 걸쳐 보안을 강화했습니다. 이 과정을 하나씩 정리합니다.

1단계 — 쿼리스트링에서 HttpOnly 쿠키로

문제점

초기 구현은 로그인 성공 후 JWT를 쿼리스트링으로 프론트에 전달했습니다.

http://localhost:3000/login?token=eyJhbGciOi...

쿼리스트링으로 토큰을 넘기면 다음과 같은 문제가 있습니다.

  • 브라우저 히스토리에 토큰이 그대로 남습니다.
  • 서버 액세스 로그에 토큰이 기록됩니다.
  • Referer 헤더를 통해 외부로 유출될 수 있습니다.

해결

OAuthSuccessHandler를 수정해 쿼리스트링 대신 HttpOnly 쿠키로 JWT를 저장했습니다.

Cookie cookie = new Cookie("accessToken", accessToken);
cookie.setHttpOnly(true);  // JS 접근 차단 → XSS 방어
cookie.setPath("/");
cookie.setMaxAge(60 * 60 * 24); // 24시간
response.addCookie(cookie);
response.sendRedirect(redirectUri); // URL에 토큰 없이 리다이렉트

토큰을 헤더와 쿠키 양쪽에서 모두 인식하도록 JwtAuthFilter도 함께 수정했습니다. Swagger 같은 API 클라이언트는 Authorization 헤더를, 브라우저는 쿠키를 자동으로 사용할 수 있게 하기 위해서입니다.

// 1순위: Authorization 헤더 (Swagger 등 API 클라이언트용)
String bearer = request.getHeader("Authorization");
if (bearer != null && bearer.startsWith("Bearer ")) return bearer.substring(7);

// 2순위: HttpOnly 쿠키 (브라우저용)
for (Cookie cookie : request.getCookies()) {
    if ("accessToken".equals(cookie.getName())) return cookie.getValue();
}

쿠키로 토큰이 이미 전달되므로, 리다이렉트 대상도 토큰 파싱이 필요 없는 홈 페이지로 바꿨습니다.

# 변경 전: 로그인 페이지로 돌아오면 토큰 파싱 필요
oauth:
  redirect-uri: "http://localhost:3000/login"

# 변경 후: 홈 페이지로 직접 이동
oauth:
  redirect-uri: "http://localhost:3000/home"

쿠키 vs 로컬스토리지

HttpOnly 쿠키로컬스토리지
XSS 방어JS 접근 불가JS로 접근 가능
요청 시 자동 전송가능직접 헤더에 담아야 함
토큰 노출URL/로그 노출 없음탈취 시 노출 위험

2단계 — 환경별 Secure/SameSite 정책

쿠키로 전환한 다음, 코드 리뷰에서 "Secure/SameSite 설정이 없어 HTTP로도 전송될 수 있고, cross-site 쿠키 전송 시 SameSite 정책 명시가 필요하다"는 지적을 받았습니다. 프론트엔드와 백엔드 배포 도메인이 서로 다르기 때문에, cross-site 요청에 대한 정책을 명확히 정해야 했습니다.

SameSite란

쿠키가 "다른 사이트에서 오는 요청"에도 같이 실려 갈지를 브라우저가 결정하는 속성으로, CSRF 공격 방어를 위해 등장한 개념입니다.

값동작언제 쓰는가
Strict완전히 같은 사이트에서 온 요청에만 전송매우 민감한 계정 설정 변경 등
Lax같은 사이트 + 최상위 GET 네비게이션에는 전송, fetch/XHR 같은 cross-site 서브 요청에는 전송 안 됨브라우저 기본값, 대부분의 로그인 쿠키
Nonecross-site 요청에도 무조건 전송프론트/백엔드 도메인이 완전히 다를 때. 반드시 Secure=true와 함께 써야 함

지금 코드는 Secure, SameSite를 아예 지정하지 않아 브라우저 기본값에 의존하고 있었습니다. Secure가 없으면 운영 환경에서도 HTTP로 JWT 쿠키가 전송될 수 있고, 프론트와 백엔드가 서로 다른 도메인(cross-site)이라면 Lax로는 fetch() 요청에 쿠키가 실리지 않아 로그인 자체가 깨질 수 있습니다.

결정

프론트가 Vercel에 배포되고 백엔드와는 완전히 다른 도메인이라는 점을 근거로, cross-site 상황으로 판단하고 SameSite=None + Secure=true 조합을 선택했습니다. jakarta.servlet.http.Cookie는 SameSite 속성을 지원하지 않아 Spring의 ResponseCookie로 교체했습니다.

# application.yml — 공통 기본값 (dev/prod, cross-site 도메인 기준)
cookie:
  secure: true
  same-site: None

# application-local.yml — local은 http라 Secure=true면 쿠키가 아예 전송 안 됨
cookie:
  secure: false
  same-site: Lax  # localhost:3000 ↔ 8080은 포트만 다른 same-site라 Lax로 충분

3단계 — 쿠키 인증을 유지하며 CSRF 방어 추가

쿠키로 JWT를 저장한 데다 SameSite=None까지 적용하고 나니, 새로운 코드 리뷰 지적이 따라왔습니다. "JwtAuthFilter가 브라우저가 자동 전송하는 accessToken 쿠키로 인증하는데 CSRF가 전역 비활성화되어 있다. STATELESS는 CSRF를 막아주지 않으며, 특히 SameSite=None으로 cross-site 쿠키 전송을 허용했기 때문에 위험이 더 커진다"는 내용이었습니다.

브라우저가 자동으로 실어 보내는 쿠키만으로 인증한다는 것은, 공격자의 페이지에서도 사용자 몰래 요청을 보내면 그 쿠키가 그대로 실려서 요청이 성공할 수 있다는 뜻입니다. 이게 CSRF(Cross-Site Request Forgery)입니다.

프론트가 HttpOnly 쿠키를 JS로 읽을 수 없으므로, Authorization 헤더 방식으로 완전히 전환하는 선택지는 없었습니다. 대신 CSRF 토큰을 활성화하는 더블 서브밋 쿠키 방식으로 방어했습니다.

  • CsrfCookieFilter를 신규 추가해 매 요청마다 CSRF 토큰을 쿠키로 강제 발급 (Spring Security 공식 SPA 가이드 패턴)
  • SecurityConfig에서 CookieCsrfTokenRepository.withHttpOnlyFalse() + CsrfTokenRequestAttributeHandler로 CSRF를 활성화 (Swagger 테스트용 엔드포인트만 예외 처리)

서버가 XSRF-TOKEN 쿠키(HttpOnly 아님)를 내려주면, 프론트는 POST/PUT/DELETE/PATCH 요청마다 이 값을 읽어 X-XSRF-TOKEN 헤더에 담아 보내야 합니다. 없으면 403이 돌아옵니다. 별도 발급 API 호출 없이 쿠키가 자동으로 세팅되므로 프론트 쪽 추가 작업은 헤더를 채워 보내는 것뿐입니다.

정리

단계바꾼 것막은 위험
1쿼리스트링 → HttpOnly 쿠키토큰이 히스토리/로그/Referer로 유출
2Secure=true + SameSite=None(cross-site 기준)HTTP 평문 전송, cross-site 요청에서 로그인 깨짐
3더블 서브밋 CSRF 토큰 활성화SameSite=None으로 넓어진 CSRF 공격 표면

각 단계는 이전 단계의 보안을 강화한 만큼 새로운 위험을 함께 데려왔습니다. 쿠키로 옮기니 CSRF 노출이 생겼고, cross-site를 허용하니 CSRF 위험이 더 커졌습니다. 보안은 한 번에 완성되는 것이 아니라, 하나를 막으면 다음 층위의 위험이 보이는 과정의 연속이라는 걸 체감했습니다.

관련 포스트

댓글

방명록을 남겨주세요