본문 바로가기
코딩 공부/web & Spring

[Web / JWT / Spring] HttpOnly 쿠키와 보안

by 현장 2025. 12. 21.

HttpOnly 쿠키와 보안

JWT를 사용하면서 인증을 유지하는데 보안적인 문제로 Access Token과 Refresh Token을 같이 해야하는 상황이었습니다. AccessToken의 경우는 유효 시간이 짧기 때문에 Pinia라는 상태 관리 라이브러리로 관리 했지만 만료 후에 재발급 받을때 RefreshToken을 사용해야 했습니다. 하지만 이 Refresh Token을 Pinia로 저장하기엔 탈취 등 여러 보안적 위험이 많이 발생하기 때문에 이에대해 고민과 추가 적인 보안이 가능한지 찾아보게 되었습니다.

✔️ 취약점

1. 세션 하이재킹 (Session Hijacking)

쿠키에 사용자의 로그인 상태를 증명하는 세션 ID가 담겨 있을 때 발생하는 가장 대표적인 공격입니다.

  • 설명: 공격자가 사용자의 쿠키를 탈취하여 서버에 제시하면, 서버는 공격자를 정상적인 사용자로 오인하게 됩니다.
  • 결과: 공격자는 비밀번호 없이 사용자의 계정에 로그인하여 개인정보를 빼내거나 유료 서비스를 이용할 수 있습니다.

2. XSS (Cross-Site Scripting)

웹사이트에 악성 스크립트를 삽입하여 사용자의 쿠키를 탈취하는 방식입니다.

  • 공격 방식: 게시판이나 댓글창에와 같은 코드를 삽입합니다.
  • 취약점: 브라우저가 자바스크립트를 통해 쿠키에 접근할 수 있도록 허용되어 있을 때 발생합니다.

3. CSRF (Cross-Site Request Forgery)

사용자가 자신의 의지와 무관하게 공격자가 의도한 행위를 특정 웹사이트에 요청하게 만드는 공격입니다.

  • 설명: 사용자가 이미 로그인되어 쿠키가 살아있는 상태에서 공격자가 만든 악성 링크를 클릭하면, 브라우저가 쿠키를 자동으로 포함해 서버에 요청을 보냅니다.
  • 결과: 사용자의 동의 없이 비밀번호 변경, 송금, 게시글 작성 등이 이루어질 수 있습니다.

4. 스니핑 (Sniffing)을 통한 평문 노출

보안 연결(HTTPS)이 아닌 일반 HTTP 연결에서 쿠키가 전송될 때 발생하는 문제입니다.

  • 설명: 네트워크 구간에서 패킷을 가로채면 쿠키 내용이 암호화되지 않은 채 그대로 노출됩니다.
  • 취약점: 공용 Wi-Fi와 같은 환경에서 특히 위험합니다.

🏷️ HttpOnly 쿠키

private static String setRefreshTokenCookie(String refreshToken, int age, boolean secure) {
    ResponseCookie cookie  = ResponseCookie
            .from("refreshToken", refreshToken)
            .httpOnly(true) // JavaScript에서 접근 불가
            .path("/")      // 애플리케이션 전체에 유효
            .maxAge(age)    // 유효시간 ex) 7 * 24 * 60 * 60 (7일간 유효)
            .build();

    return cookie.toString();
}

처음 추가한 조치는 위와 같이 HttpOnly 쿠키로 Refresh Token을 저장하여 관리하는 것이었습니다. HttpOnly 쿠키는 JavaScript에서 접근할 수 없는 쿠키로,  클라이언트 측 스크립트에서는 이 쿠키에 접근할 수 없으므로 XSS 공격을 예방할 수 있습니다. 이 방식은 보안을 강화하고, 자동으로 쿠키를 서버에 전송하기 때문에 인증 처리가 간편해진다는 장점이 존재합다.

🏷️ Secure 속성 추가

private static String setRefreshTokenCookie(String refreshToken, int age, boolean secure) {
        ResponseCookie cookie  = ResponseCookie
                .from("refreshToken", refreshToken)
                .httpOnly(true)
                .secure(secure) // 추가
                .path("/")
                .maxAge(age)
                .build();

        return cookie.toString();
    }

추가적으로 위 Secure 속성을 추가하여 요청이 HTTPS와 같은 보안 채널을 통해 전송되는 경우에만 쿠키를 전송하도록 브라우저에 지시하도록 했습니다. 이를 통해 암호화되지 않은 요청에서 쿠키가 전달되지 않도록 보호하여 네트워크 스니핑 방지 할 수 있습니다.

🏷️ SameSite 설정

private static String setRefreshTokenCookie(String refreshToken, int age, boolean secure) {
        ResponseCookie cookie  = ResponseCookie
                .from("refreshToken", refreshToken)
                .httpOnly(true)
                .secure(secure)
                .path("/")
                .maxAge(age)
                .sameSite("Lax") // SameSite=Lax 속성 적용
                .build();

        return cookie.toString();
    }

SameSite는 CSRF를 해결하기 위해 만들어진 기술입니다. 위와 같이 쿠키에 설정하여 크로스 사이트로 전송하는 요청의 경우 쿠키 전송에 제한을 두는 것이 목표입니다. SameSite는 세 가지 종류를 선택할 수 있고, 각각의 동작 방식이 다릅니다.

✅ 속성

1. None

  • 든 요청에서 허용되지만, 반드시 Secure 속성과 함께 사용해야 합니다.

 2. Strict

  • 나랑 주소가 완전히 똑같은 곳에서 온 요청에만 내 쿠키를 실어 보냅니다.
  • 프론트/백엔드 분리 상황
    • 프론트: frontend.com / 백엔드: api.com 이라면 주소가 다른데 이 경우 프론트에서 백엔드로 API 요청을 보낼 때 브라우저가 쿠키를 아예 안 보냅니다.
    • 결과적으로 백엔드는 "로그인 안 된 사용자"로 판단하여 요청을 거절하거나 실패하게 됩니다.

3. Lax 

    • 웬만하면 안 보내지만, 사용자가 직접 링크를 클릭해서 들어오는 등의 안전한 이동(GET)에는 쿠키를 실어 보냅니다.
    • 프론트/백엔드 분리 상황:
      • 프론트(frontend.com)에서 스크립트(Fetch/Axios)로 api.com에 요청을 보내는 것은 '사용자가 링크를 클릭한 것'이 아닙니다.
      • Lax 환경에서도 프론트(Cross-site)의 API 요청에는 쿠키가 포함되지 않습니다. (기본적으로 Strict와 비슷하게 동작함)

CSRF를 막기 위해 SameSite는 Lax나 Strict로 사용하는 것이 좋으며, 구글 크롬도 업데이트되면서 SameSite 속성의 기본 값을 None에서 Lax로 상향 조절했습니다.

🏷️ Controller에 적용

// 로그인
@PostMapping("/login")
public Response<UserLoginResponse> login(
        @RequestBody UserLoginRequest request,
        HttpServletResponse httpServletResponse
) {
    // Service에서 AT와 RT를 모두 포함하는 TokenResponse를 받습니다.
    TokenDto tokenDto = userAccountService.login(request.getUserId(), request.getPassword());

    // RT를 HttpOnly 쿠키로 설정합니다.
    String sameSiteHeader = setRefreshTokenCookie(tokenDto.getRefreshToken(), REFRESHTOKEN_AGE, true);
    httpServletResponse.setHeader("Set-Cookie", sameSiteHeader);

    return Response.success(UserLoginResponse.ofDto(tokenDto));
}

// 로그아웃
@PostMapping("/logout")
public Response<Void> logout(
        Authentication authentication,
        HttpServletResponse httpServletResponse
) {
    if (authentication == null) {
        log.warn("인증되지 않은 사용자(/logout) 요청. 토큰이 없거나 이미 만료된 상태일 수 있습니다.");
        // 에러 반환
    }
    // 서버측 토큰 무효화 (DB/Redis 등에서 삭제)
    userAccountService.logout(authentication.getName());
    // 브라우저 쿠키 삭제를 위해 Max-Age=0인 쿠키 헤더 설정
    String deleteCookieHeader = setRefreshTokenCookie("", 0, true);
    httpServletResponse.setHeader("Set-Cookie", deleteCookieHeader);

    return Response.success();
}

📖 Reference

Nana.velog

스패로우(sparrow.im)