
이번 포스팅에서는 OAuth 2.0 인증 과정에서 발생한 Refresh Token 전송 문제와 이를 해결한 과정을 다룰 것이다. 문제 상황, 문제 해결 전후의 과정, 그리고 개선된 보안 방안에 대해 단계별로 설명하고, 실무에 적용할 수 있는 코드 예시를 함께 공유할 예정이다.
OAuth 2.0 인증 과정에서 Refresh Token의 보안을 강화하기 위해 서버 내에서 AES-128 알고리즘을 사용하여 암호화한 후 전달하는 방식을 도입하였다. 이 방법은 Refresh Token이 외부로 노출되는 것을 방지하여 보안을 강화하려는 목적에서 도입된 것이었다. 하지만 URL 리다이렉트 과정에서 예상치 못한 문제가 발생하였다. 암호화된 토큰을 URL로 전달하는 과정에서 URL-safe 인코딩을 적용하지 않아 오류가 발생한 것이다.
암호화된 Refresh Token을 URL을 통해 전달할 때, URL-safe 인코딩이 적용되지 않아 문자열이 깨지는 상황이 발생하였다. 이는 암호화된 토큰이 URL에 포함될 때 URL에서 인식되지 않는 특수 문자가 포함되었기 때문이다. 결과적으로 토큰이 제대로 전달되지 않아 인증 과정에서 오류가 발생하였다. 특히, 클라이언트에서는 전달받은 Refresh Token을 사용해 Access Token을 재발급받으려 했지만, 서버에서는 해당 토큰이 잘못된 것으로 인식되어 재발급이 이루어지지 않았다.
private void handleGuestLogin(HttpServletRequest request, HttpServletResponse response,
PrincipalDetails principalDetails) throws IOException {
log.info("New Social Login Member");
TokenDto tokenDto = jwtTokenProvider.generateTokenDto(principalDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
// URL-safe 인코딩 적용 전 코드
String targetUrl = UriComponentsBuilder.fromUriString(frontendServer + "/members/oauth2/join")
.queryParam("email", principalDetails.getUsername())
.queryParam("role", principalDetails.role())
.queryParam("accessToken", BEARER_PREFIX + accessToken)
.queryParam("refreshToken", encryptedRefreshToken) // URL-safe 인코딩 미적용
.build()
.encode(StandardCharsets.UTF_8)
.toUriString();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
위 코드에서 encryptedRefreshToken을 URL의 쿼리 파라미터로 전달할 때 URL-safe 인코딩을 적용하지 않아 문제가 발생하였다. 이로 인해 암호화된 토큰이 URL에 포함될 때 일부 문자가 깨지거나 변조되어 인증 과정에서 오류가 발생하였다.
다음은 문제가 발생한 경우 실제 URL의 예시이다:
https://frontend.example.com/members/oauth2/join?email=user@example.com&role=USER&accessToken=Bearer%20eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...&refreshToken=U2FsdGVkX1+Q%2F4g*ZjFsd==bW9VxEJ
위 URL에서 refreshToken 파라미터에 포함된 암호화된 토큰이 깨진 것을 볼 수 있다. 이는 +, *, = 등의 문자가 URL에서 제대로 인식되지 않아 발생한 문제이다. 결과적으로 클라이언트는 해당 Refresh Token을 서버로 다시 전송하여 Access Token을 재발급받으려고 했지만, 서버에서는 이 토큰을 유효하지 않은 것으로 판단하여 재발급이 이루어지지 않았다.
URL 리다이렉트 과정에서 AES-128로 암호화된 Refresh Token이 안전하게 전달되지 않는 현상이 발견되었다. 이는 Base64 인코딩된 문자열이 URL-safe하지 않은 문자를 포함하고 있었기 때문이다. 이러한 이유로 암호화된 토큰이 URL에서 변조되거나 손상되어 제대로 전달되지 않는 문제가 발생하였다.
이 문제를 해결하기 위해 URL-safe 인코딩을 적용하였다. Base64 인코딩 대신 URL-safe Base64 인코딩을 사용하여 URL로 안전하게 전달될 수 있도록 수정하였다.
String encryptedTokenUrlSafe = Base64.getUrlEncoder().withoutPadding().encodeToString(encryptedRefreshToken.getBytes(StandardCharsets.UTF_8));
String targetUrl = UriComponentsBuilder.fromUriString(frontendServer + "/members/oauth2/join")
.queryParam("email", principalDetails.getUsername())
.queryParam("role", principalDetails.role())
.queryParam("accessToken", BEARER_PREFIX + accessToken)
.queryParam("refreshToken", encryptedTokenUrlSafe)
.build()
.encode(StandardCharsets.UTF_8)
.toUriString();
위 코드에서 Base64.getUrlEncoder()를 사용하여 URL-safe Base64 인코딩을 적용하였다. 이를 통해 암호화된 토큰이 안전하게 URL로 전달될 수 있게 되었으며, 이로 인해 발생하던 오류는 해결되었다.
다음은 문제가 해결된 후 올바르게 전달된 URL의 예시이다:
https://frontend.example.com/members/oauth2/join?email=user@example.com&role=USER&accessToken=Bearer%20eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...&refreshToken=U2FsdGVkX1-Q_4g-ZjFsd-bW9VxEJ
위 URL에서는 refreshToken 파라미터가 URL-safe하게 인코딩되어 전달된 것을 확인할 수 있다. 이를 통해 클라이언트와 서버 간의 통신에서 더 이상 토큰이 깨지는 문제가 발생하지 않았다.
URL-safe 인코딩을 통해 토큰 전달 과정에서의 오류는 해결되었지만, 여전히 보안적인 이슈가 남아 있었다. 암호화된 Refresh Token이 URL로 직접 전달되는 방식은 보안적으로 취약할 수 있다는 점을 알게 되었다. 사용자가 URL을 공유하거나, 브라우저 히스토리에 해당 URL이 남는 경우 암호화된 토큰이 노출될 위험이 존재하였다.
이를 해결하기 위해 Refresh Token을 HTTPOnly 속성을 가진 쿠키로 전달하는 방법을 도입하였다. HTTPOnly 쿠키는 JavaScript를 통해 접근할 수 없기 때문에 클라이언트 측에서 토큰이 노출될 위험을 크게 줄일 수 있다.
public void setRefreshToken(HttpServletResponse response) {
String refreshToken = "sample_refresh_token";
// HTTPOnly 쿠키 생성
ResponseCookie cookie = ResponseCookie.from("refreshToken", refreshToken)
.httpOnly(true)
.secure(true)
.path("/")
.maxAge(7 * 24 * 60 * 60) // 7일
.build();
response.addHeader("Set-Cookie", cookie.toString());
}
위 코드에서는 ResponseCookie를 사용하여 Refresh Token을 HTTPOnly 쿠키로 설정하고 클라이언트에 전달한다. 이를 통해 토큰이 URL을 통해 전달되지 않고, 브라우저에 안전하게 저장될 수 있다.
최종적으로 Refresh Token을 HTTPOnly 쿠키로 전달함으로써 보안성을 한층 강화할 수 있었다. 이를 통해 인증 과정에서 발생할 수 있는 잠재적인 보안 이슈를 방지하고, 사용자 정보의 안전한 보호를 실현할 수 있었다. 더불어, HTTPOnly 쿠키 사용으로 인해 토큰이 URL이나 로컬 스토리지와 같은 상대적으로 취약한 저장소에 노출되는 위험을 근본적으로 차단할 수 있었다. 이 과정에서 클라이언트 측에서의 보안 유지와 토큰의 안전한 전달에 대한 중요성을 더욱 실감하게 되었으며, 이는 사용자 경험의 안전성을 높이는 데 결정적인 역할을 했다.
이번 트러블 슈팅은 단순히 발생한 문제를 해결하는 것에 그치지 않고, 시스템 전체의 보안 구조를 재점검하고 강화하는 계기가 되었다. 특히, 보안에 대한 중요성을 체감하였으며, 보안 설계 초기 단계에서부터 철저히 검토하는 것이 얼마나 중요한지 깨닫게 되었다. 이를 바탕으로 향후 유사한 보안 문제를 미연에 방지하기 위한 체계적인 계획 수립의 필요성을 느꼈고, 사용자 데이터를 보호하는 것이 개발자의 가장 중요한 책임 중 하나임을 다시 한 번 확인할 수 있었다.