본 포스팅은 사용자 로그인 시도 시 Spring Security Filter Chain 을 거쳐 사용자 인증이 정상적으로 완료된 상태에서 JWT 가 발급되는 과정을 설명한다.
Access Token 은 Response Body 에, Refresh Token 은 Cookie 에 설정한다.
💡 JwtTokenProvider
- JwtTokenProvider 클래스는 JWT (JSON Web Token)를 생성하고 검증하는 등 JWT 관련된 모든 기능을 전담하여 수행하는 클래스이다.
- 로그인 시도 시 사용자의 인증 정보를 기반으로하여 액세스 토큰과 리프레시 토큰을 발급하며, 이를 포함한 토큰 정보를 TokenInfo 객체로 반환한다.
💡 Access Token & Refresh Token
- 액세스 토큰 (Access Token)
사용자의 신원을 확인하고 권한을 부여하는 데 사용된다. 토큰은 일정 기간 동안 유효하며, 이 기간이 만료되면 재인증이 필요하다.- 리프레시 토큰 (Refresh Token)
액세스 토큰이 만료되었을 때, 리프레시 토큰을 사용하여 새로운 액세스 토큰을 갱신한다.- 토큰 만료 및 갱신
주로 보안적인 이유로 액세스 토큰이 짧게 유지되고, 리프레시 토큰이 보다 오래 유지된다.
public TokenInfo generateTokenInfo(Authentication authentication, HttpServletResponse response) {
// 현재 시간 및 사용자 권한 가져오기
long currentTimeMillis = System.currentTimeMillis();
String authorities = getAuthorities(authentication);
// 액세스 토큰 및 리프레시 토큰 생성
String accessToken = generateToken(authentication.getName(), authorities, currentTimeMillis + ACCESS_TOKEN_EXPIRE_TIME);
String refreshToken = generateToken(authentication.getName(), authorities, currentTimeMillis + REFRESH_TOKEN_EXPIRE_TIME);
// 엑세스 토큰을 쿠키에 저장
storeRefreshTokenInCookie(response, refreshToken);
// 토큰 정보를 담은 객체 생성
TokenInfo tokenInfo = TokenInfo.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.accessTokenExpiresIn(currentTimeMillis + ACCESS_TOKEN_EXPIRE_TIME)
.refreshToken(refreshToken)
.build();
return tokenInfo;
}
private String generateToken(String subject, String authorities, long expiration) {
Date expirationDate = new Date(expiration);
JwtBuilder jwtBuilder = Jwts.builder()
.setSubject(subject)
.claim(AUTHORITIES_KEY, authorities)
.setExpiration(expirationDate)
.signWith(key, SignatureAlgorithm.HS512);
return jwtBuilder.compact();
}
💡 JWT (JSON Web Token)
- JWT 페이로드에 포함되는 클레임 집합의 구성요소로는 다음과 같다.
sub (Subject) : 토큰의 주체를 나타낸다. 여기서는 사용자 아이디를 사용하였다. auth (Authorities) : 사용자의 권한을 나타낸다. 여기서는 역할 정보를 사용하였다. exp (Expiration Time) : 토큰의 만료 시간을 나타낸다.
- JWT 헤더 및 서명에 포함되는 구성요소로는 다음과 같다.
alg (Algorithm): 암호화 알고리즘을 나타낸다. 여기서는 HMAC-SHA512 알고리즘을 사용하여 서명하였다. typ (Type) : 토큰 타입을 나타낸다. 여기서는 JWT 를 사용하였다. sign (Signature): 앞의 헤더와 페이로드를 비밀 키로 서명한 결과를 나타낸다.헤더와 페이로드는 Base64 인코딩된 문자열로 구성되며, 서명은 HMAC-SHA512 알고리즘을 사용하여 헤더와 페이로드를 비밀키로 서명한다. 이들은 점(.) 으로 구분되어 JWT가 완성된다.
Base64UrlEncode(Header) + "." + Base64UrlEncode(Payload) + "." + Signature
- compact() 메서드는 빌더 객체를 사용하여 JWT 문자열로 변환한다.
private void storeRefreshTokenInCookie(HttpServletResponse response, String refreshToken) {
ResponseCookie cookie = ResponseCookie.from(REFRESH_TOKEN, refreshToken)
.domain(".tr1ll1on.site")
.httpOnly(true)
.secure(true)
.path("/")
.sameSite("None")
.build();
response.addHeader("Set-Cookie", cookie.toString());
}
아래와 같은 옵션들이 적용된 ResponseCookie 객체를 생성하고, Set-Cookie 를 통해 브라우저에 쿠키를 저장한다.
🍪 SameSite : 쿠키가 어떤 상황에서 전송되는지 제어하는 역할
- None : 쿠키가 모든 상황에서 전송되도록 허용한다. Cross-Origin 인 경우에도 쿠키 전송을 허용한다. 이를 사용하려면 Secure 속성도 함께 설정되어야 한다.
- Lax : Cross-Origin 인 경우 GET 요청의 메서드에서만 쿠키를 전송할 수 있다.
- Strict : 모든 상황에서 쿠키가 전송되려면 해당 요청이 완전히 같은 사이트 내부에서 이루어져야 한다. 즉 Same-Origin 인 경우에만 가능하다.
✋ 여기서 잠깐 : Same-Origin 및 Same-Site 이해
- Same-Origin 및 Cross-Origin
동일한 스키마, 호스트 이름, 포트가 조합된 웹사이트는 Same-Origin (동일 출처) 로 간주된다. 그 외 모든 것은 Cross-Origin (교차 출처) 로 간주된다.- Same-Site 및 Cross-Site
스키마와 eTLD+1이 동일한 웹사이트는 Same-Site (동일 사이트) 로 간주된다. 스키마가 다르거나 eTLD+1이 다른 웹사이트는 Cross-Site (교차 사이트) 로 간주된다.
🍪 Secure : 프로토콜에 따른 쿠키 전송 여부를 결정
- true : HTTPS 를 통해 통신하는 경우에만 쿠키를 전송한다.
🍪 HttpOnly : 자바 스크립트에서 쿠키 접근 여부를 결정
- true : JavaScript 로 쿠키에 접근이 불가능하다.
🍪 Path : 쿠키가 전송될 수 있는 경로 설정
- / : 모든 경로의 요청에서 쿠키 전송이 가능하다.
- /auth : /auth 를 포함한 세부 경로의 요청에서만 쿠키 전송이 가능하다.
브라우저 쿠키에 리프레시 토큰이 잘 담겨진 모습을 볼 수 있다.

TokenInfo tokenInfo = TokenInfo.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.accessTokenExpiresIn(currentTimeMillis + ACCESS_TOKEN_EXPIRE_TIME)
.refreshToken(refreshToken)
.build();
해당 토큰의 그랜트 타입을 Bearer Token 로 설정한다. Bearer Token 그랜트 타입은 액세스 토큰 자체가 인증 수단으로 사용되며, 요청의 헤더에 토큰을 담아서 보낸다.
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(
@RequestBody LoginRequest loginRequest, HttpServletResponse response
) {
return ResponseEntity.ok(authService.login(loginRequest, response));
}
@Transactional
public LoginResponse login(LoginRequest loginRequest, HttpServletResponse response) {
Authentication authentication = authenticateUser(loginRequest);
TokenInfo tokenInfo = jwtTokenProvider.generateTokenInfo(authentication, response);
Long userId = Long.valueOf(authentication.getName());
User user = userRepository.findById(userId)
.orElseThrow(() -> new InValidUserException(InValidUserExceptionCode.USER_NOT_FOUND));
return LoginResponse.builder()
.userDetails(
LoginResponse.UserDetailsResponse.builder()
.userId(userId)
.userEmail(user.getEmail())
.userName(user.getName())
.build()
)
.tokenInfo(tokenInfo)
.build();
}
응답 바디에 토큰과 관련된 정보가 잘 담긴 것을 볼 수 있다.

개발 초기에는 해당 Access Token 은 Bearer Token 타입으로, HTTP Response Header 의 Authorization 에 추가하여 전송하였다.
다만 프론트 엔드 쪽에서 Header 에서 Access Token 을 추출하는 과정이 번거롭다 하여, Body 에 담아서 보내는 방식으로 변경하였다.
해당 포스팅에서 Header 에 담아 보내는 과정도 기록하려고 한다.
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest loginRequest, HttpServletResponse response) {
return authService.login(loginRequest, response);
}
@Transactional(readOnly = true)
public ResponseEntity<LoginResponse> login(LoginRequest loginRequest, HttpServletResponse response) {
Authentication authentication = authenticate(loginRequest);
TokenDto tokenDTO = jwtTokenProvider.generateTokenDto(authentication, response);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + tokenDTO.getAccessToken());
User user = userRepository.findByEmail(loginRequest.getEmail())
.orElseThrow(() -> new UserNotFoundException(TrillionExceptionCode.USER_NOT_FOUND));
LoginResponse loginResponse = LoginResponse.builder()
.email(user.getEmail())
.id(user.getId())
.name(user.getName())
.build();
return ResponseEntity.ok().headers(headers).body(loginResponse);
}
이렇게 생성된 JWT 토큰을 JWT Debugger 에서 디코딩하고 검증할 수 있다.