
Access Token 만료 시 자동 로그인을 유지하기 위해 Refresh Token을 도입했고,
보안 강화를 위해 Refresh Token을 Redis 저장 + HttpOnly Cookie에 저장하도록 개선했다.
서버에만 Refresh Token 원본을 보관하고,
사용자는 암호화된 쿠키에만 저장
사용자는 한번 로그인한 사이트에서는 계속 로그인이 유지되길 바란다.
자주 방문하는 사이트를 매번 로그인해야 한다면 사용자 경험의 저하로 이어진다.
허나 이를 위해 Access Token의 유효기간을 길게 설정하면, 제 3자가 Access Token을 탈취해서 악의적인 사용을 할 수 있는 위험성이 생긴다.
이를 해결하기 위해서 Access Token의 유효기간은 짧게 설정하고 유효 기간이 지나서 만료 되었을 때 로그인 없이 새롭게 Access Token을 발급받을 수 있게 도와주는 역할을 Refresh Token이 한다.
Refresh Token은 Access Token 보다 더 긴 유효기간을 가지고 있으며 이 유효기간 동안 만료된 Access Token을 새롭게 발급받을 수 있게 한다.

사용자가 로그인에 성공하면 Access Token과 Refresh Token을 발급해서 전달한다. 이때 Refresh Token은 사용자한테는 쿠키에 담아서 저장해서 보내주고 서버는 Redis에 저장한다.
이후 사용자의 Access Token이 만료되면, 사용자는 보유하고 있는 쿠키 안의 Refresh Token을 서버에 전달하고 서버는 해당 Refresh Token을 처음 발급해줄 때 서버에 저장해놓은 Refresh Token과 비교 및 검증을 한다.
검증을 통과하면 사용자 측으로 새 Access Token과 Refresh Token을 다시 발급한다.
Refresh Token도 만료되었다면, 다시 로그인을 하도록 요청한다.
사용자가 로그아웃을 하면, Refresh Token을 삭제해서 사용할 수 없게 한다.
TTL을 짧게 설정
Refresh Token Rotation
Access Token을 재발급 할 때 Refresh Token도 갱신을 해주지 않는다면
공격자가 탈취한 Refresh Token으로 만료될 때까지 새 Access Token을 계속 발급 받을 수 있음
Refersh Token은 긴 수명을 갖고 Access Token을 무한대로 재발급할 수 있는 권한을 가지고 있다.
탈취되면 계정이 그대로 털리는 수준..
그래서 최대한 안전한 곳에 저장해야 한다.
1. Local Storage
2. Session Storage
3. JS 변수 메모리
4. HttpOnly Cookie
HttpOnly 속성 덕분에 브라우저 기본 보안 정책으로 JS가 접근할 수 없게 보호됨
API 호출 시 매번 Refresh Token을 헤더 / 바디에 싣지 않아도 된다.
특히 Access Token 만료 시
Refresh Token은 프론트가 다루면 안되는 위험한 정보이기 때문에
서버가 내부적으로 처리하는게 가장 안전하다.
Cookie는 CSRF 공격에 취약할 수 있지만
을 통해 충분히 방어할 수 있다.
MySQL 같은 데이터베이스에 Refresh Token을 저장해도 되지만
왜 굳이 Redis에 저장해서 관리할까?
Redis는 In-Memory 데이터베이스이다.
프로세서가 직접 접근할 수 있는 RAM에 데이터를 저장한다.
HDD나 SSD같은 디스크를 사용하면 요청이 있을 시 데이터를 디스크에서 Ram으로 불러와서 처리하는 과정이 추가되기 때문에
Redis를 사용하면 보조기억장치에서 데이터를 가져오는 비용이 절약된다.
즉, 읽고 쓰는 연산의 속도가 훨씬 빨라진다.
1. 만료된 Refresh Token의 관리
2. 속도적인 측면
// 검증 후 AccessToken, RefreshToken 발급
public LoginResDto issueTokens(Member member) {
// access token & refresh token 발급
String accessToken = jwtTokenProvider.createAccessToken(member.getEmail(), member.getRole().toString());
String refreshToken = jwtTokenProvider.createRefreshToken(member.getEmail());
// Redis 저장
RefreshToken redisRefreshToken = RefreshToken.builder()
.memberId(member.getId())
.token(refreshToken)
.expiration(60 * 60 * 24 * 3 * 1000L)
.build();
refreshTokenRepository.save(redisRefreshToken);
return LoginResDto.builder()
.id(member.getId())
.name(member.getName())
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
// Refresh Token 유효성 검증 후 Access Token, Refresh Token 재발급
public LoginResDto reissueTokens(String refreshToken) {
// 유효성 검증
if (!jwtTokenProvider.validateToken(refreshToken)) throw new IllegalArgumentException("Expired / Invalid Refresh Token");
// email 추출 및 회원 조회
String email = jwtTokenProvider.getEmailFromToken(refreshToken);
Member member = memberRepository.findByEmail(email).orElseThrow(() -> new EntityNotFoundException("존재하지 않는 회원입니다."));
// Redis에 저장되어 있는 Token과 일치하는지 검증
RefreshToken stored = refreshTokenRepository.findById(member.getId()).orElse(null);
if (stored == null || !stored.getToken().equals(refreshToken)) throw new IllegalArgumentException("Invalid Refresh Token");
// 새 Access Token 및 Refresh Token 발급 및 저장
String newAccessToken = jwtTokenProvider.createAccessToken(email, member.getRole().toString());
String newRefreshToken = jwtTokenProvider.createRefreshToken(email);
stored.setToken(newRefreshToken);
stored.setExpiration(60 * 60 * 24 * 3 * 1000L);
refreshTokenRepository.save(stored);
return LoginResDto.builder()
.id(member.getId())
.name(member.getName())
.accessToken(newAccessToken)
.refreshToken(newRefreshToken)
.build();
}
// logout시 Redis에 저장된 Refresh Token 삭제
public void logout(Long id) {
refreshTokenRepository.deleteById(id);
}
//Refresh Token 쿠키로 설정
ResponseCookie cookie = ResponseCookie.from("refreshToken", loginResDto.getRefreshToken())
.httpOnly(true)
.secure(false) // https 아니면 쿠키가 안들어가므로 개발중엔 false
.sameSite("Strict")
.path("/")
.maxAge(Duration.ofDays(3))
.build();
Refresh Token을 Redis + HttpOnly Cookie 기반으로 관리하고
Rotation 정책을 적용해 재발급 시마다 새로운 토큰을 발급함으로서
보안성과 사용자 경험을 모두 챙긴 인증 시스템을 구축했다.
Refresh Token을 Redis에 저장하고
쿠키 기반으로 안전하게 관리할 수 있는 구조를 갖추게 되었다.
또한 Rotation 정책을 적용해 토큰 탈취 시도를 감지할 수 있는
인증 보안 체계를 완성했다.
기능이 먼저 툭 튀어 나왔을리가 없다.
왜 이런 기능이 필요하지?
기능이 동작하기 위한 원리가 뭐지?
이렇게 동작하기 위해서 어떤 개념들이 들어가야 하지?
라는 고민들이 모여 나온 것이 기능이라고 생각한다.
개발을 진행할수록 단순히 기능만 구현하는 것이 아니라
보안, 성능, 사용자 경험을 모두 고려하는 것이 중요하다는 걸 몸소 느끼는 중이다.