주제내용세션 인증 방식JWT(Json Web Token) 이란?JWT의 구성토큰 인증 방식의 장점토큰 인증 방식의 단점그럼 어떻게 단점을 보완할까?Refresh TokenToken 저장 장소의 선택프로젝트에 적용했던 JWT 인증 프로세스최초 인증 시최초 인증 이후의 인증 방식액세스 토큰이 만료되었을 시액세스 토큰과 리프레쉬 토큰이 모두 만료되었을 시
주제
내용
세션 인증 방식
JWT를 설명하기 앞서 기본적인 인증 방식인 세션 인증 방식을 이해해야 합니다.
서버 세션 인증 방식은 다음과 같은 몇가지 문제점이 있습니다.
- 요청이 들어올 때마다 세션 저장소에서 세션이 있는지 확인을 해야합니다.
- 서버가 다중 서버로 확장될 때 세션의 정합성이 맞지 않는 문제가 발생합니다.
다중 서버 환경에서 인증 정보의 정합성 문제
다중 서버 환경에서 A 서버에 로그인을 해서 인증 정보를 서버내에 저장하고, 로드밸런싱에 의해 다음 요청은 B 서버로 요청이 갈 경우 B 서버에는 인증 정보가 없으므로 원하는 결과를 얻지 못하는 문제가 발생합니다.

이러한 문제를 해결하는 방법에는 몇가지가 있습니다.
Sticky Session
사용자가 A서버에 요청 했다면 다음 요청도 최초 요청한 서버인 A서버에 요청하도록 만드는 방식(로드밸런서가 쿠키에 저장되어있는 서버 정보를 토대로 요청을 보내줍니다.)
이러한 방식은 특정 서버로만 요청이 가기 때문에 요청이 몰릴수도 있고 로드밸런서의 효과를 제대로 받지 못합니다.
Session Clustering
모든 서버에 세션을 복제하는 방법입니다. 모든 서버에 세션이 저장되어 있으므로 A서버 이후 B서버로 요청이 갔을 때 세션이 존재하기 때문에 아까 언급했던 문제가 해결될 것입니다. 하지만 그만큼 메모리의 부하가 심각해질 수 있습니다. 그리고 복사 작업 또한 트래픽이 발생합니다.
별도의 Session Storage
여러 서버에서 공용으로 사용하는 Session Storage를 별도의 외부 서버로 두어 해결하는 방법입니다.
서버끼리 불필요한 네트워크 통신 과정이 필요없고 서버에 문제가 발생하더라도 세션 스토리지와는 별개이므로 영향을 받지 않습니다.
Disk Database나 In-memory DB를 사용할 수 있는데 영구적으로 저장할 필요 없는 세션은 입출력 속도가 압도적으로 빠른 In-memory DB 방식이 좀 더 적합하다고 볼 수 있습니다.
하지만 Session Storage에 문제가 발생하면 모든 WAS가 영향을 받고, 별도의 외부 Session Storage를 사용하는 것이기 때문에 네트워크 추가적인 I/O가 발생해 트래픽 부하로 인해 문제가 발생할 수도 있습니다.
JWT(Json Web Token) 이란?
JWT 인증방식은 토큰 기반 인증 방식의 한 형태로, 서버 세션 인증 방식이 가진 단점을 어느정도 보완할 수 있는 인증 방식입니다.
인증 정보를 서버사이드에 저장하지 않고 인증에 사용되는 Json형식의 토큰을 클라이언트 사이드에 저장을 해 서버의 자원을 비교적 덜 사용하는 방식입니다.
이 토큰은 외부로 노출되고 단순히 base64 방식으로 인코딩되기 때문에 패스워드와 같은 민감정보는 담지 않아야합니다.
JWT의 구성
JWT는 '.'을 구분자로 아래와 같이 세 부분으로 나누어져 있습니다.

- 헤더(Header)
- typ : 토큰 타입 지정
- alg : 해싱 알고리즘 지정
- 내용(payload)
- 토큰에 담은 정보를 저장
- 클레임(Claim) : 담긴 정보의 단위. registered, public, private 클레임으로 나뉩니다.
- 서명 (signature)
- 헤더의 인코딩 값과 정보의 인코딩 값을 합친 후 주어진 비밀키로 해쉬하여 생성
- 헤더(Header)와 내용(payload)는 단순히 인코딩된 값이기 때문에 제 3자가 복호화할 수 있지만 signature 값은 서버측에서 관리하는 비밀키 값이 유출되지 않는 이상 복호화 할 수가 없습니다. 따라서 signature는 토큰의 위변조 여부를 확인하는데 사용됩니다.
토큰 인증 방식의 장점
- JWT는 기본적으로 서버에 저장되지 않기 때문에 Stateless합니다. 서버측에서는 별도의 저장 없이 토큰을 토대로만 사용자가 허가된 사용자인지 아닌지만 판단하면 됩니다.
- JWT는 서버를 확장할 때 용이합니다. 서버가 분산되어 있어도 클라이언트 사이드에 보관하고 있던 토큰을 사용하면되고 DB에 접근할 필요 없이 토큰 내 데이터만 사용하면 됩니다.
- 토큰을 통해서 플랫폼간 권한을 공유할 수 있습니다.(OAuth)
- 세션 방식과는 다르게 토큰 인증 방식은 헤더로 토큰을 넘길 수 있습니다. CORS로부터 자유로울 수 있게됩니다.
토큰 인증 방식의 단점
- stateless하기 때문에 서버에서 강제로 토큰을 비활성화 할 수 없습니다.
토큰이 해커에게 탈취되었다 하더라도 서버 측에서는 이 토큰을 무효화할 방법이 없습니다. 또한 로그아웃을 강제할 수 없습니다.
- 토큰이 만료되면 매번 재발급 받아야합니다.
→이러한 문제들을 해결하기 위해서 액세스 토큰과 리프레쉬 토큰을 사용하는 방법있습니다.
- 서버 내부의 안전한 저장소에 저장되는 것이 아니라 외부로 노출되어 있으므로 비교적 보안에 취약할 수 있습니다.
그럼 어떻게 단점을 보완할까?
Refresh Token
앞서 말했다시피 리프레쉬 토큰을 사용하면 어느정도 해결이 됩니다.
리프레쉬 토큰이란 액세스 토큰을 재발급 받기 위해 사용되는 특수목적의 토큰입니다.
토큰 만료시 매번 다시 로그인해야 하는 토큰 인증 방식의 단점을 보완하기 위해 리프레쉬 토큰을 서버사이드에 저장을해 토큰이 만료되었을 때만 DB에 접근해 다시 액세스 토큰을 재발급 해줍니다.
매번 세션 스토리지에 접근하는 세션 인증방식과는 만료되었을 때만 저장소에 접근한다는 차이점이 있습니다.
그리고 액세스 토큰의 만료 기간을 짧게, 리프레쉬 토큰의 만료기간을 비교적 길게 한다면 액세스 토큰이 탈취 되었다 하더라도 리프레쉬 토큰은 서버 사이드에서 관리하기 때문에 리프레쉬 토큰을 비활성화 해 액세스 토큰의 재발급을 막아 서버에서 어느정도 컨트롤 할 수 있습니다.
Token 저장 장소의 선택
토큰은 서버 내에서 관리하는 것이 아니라 브라우저에 저장되기 때문에 브라우저에서도 어떤 곳에 저장을 해야 안전할지 고민을 해야합니다.
대표적으로 Local Storage, Cookie, 자바 스크립트 내 변수가 있습니다.
- LocalStorage
- 자바스크립트로 Local Storage에 접근할 수 있기 때문에 자바스크립트의 조작으로 토큰을 탈취하여 악의적인 공격을하는 XSS 공격에 취약할 수 있습니다.
- Cookie 처럼 자동으로 토큰을 보내는 것이 아니라 사용자가 얻은 인증 정보를 통해 악의적인 요청을 보내는 CSRF 공격에는 비교적 안전합니다.
- Cookie
- HttpOnly 옵션 설정으로 자바스크립트로부터의 접근을 막아 XSS에 비교적 안전합니다.
- 요청마다 유저의 의도와는 상관없이 쿠키를 전송하기 때문에 CSRF 공격에 취약할 수 있습니다.
- CORS 문제가 발생할 수 있습니다.
- 쿠키는 최대 4kb만 저장할 수 있어 정보 저장에 제한적입니다.
- 스크립트 내 변수
- 이거는 잘 모르겠음 똑같이 XSS 공격에 취약한거 아닌가..?
- CSRF는 확실히 LocalStorage보다 안전
- 브라우저 종료, 페이지 리로드, 이동시 삭제
- 매번 재발급 해줘야합니다.
개인적인 의견으로 LocalStorage나 Cookie에 저장하고 XSS나 CSRF를 방어하는게 좋지 않을까 생각합니다.
프로젝트에 적용했던 JWT 인증 프로세스
다음은 인스타뀨램과 SFAM에서 적용했던 인증 프로세스입니다.
최초 인증 시
- 사용자는 로그인을 위해 /api/users/signin 으로 ID와 Password와 함께 로그인 요청을 보냅니다.
- API Server의 인증을 담당하는 Controller인 AuthController는 AuthService의 signIn 함수를 호출합니다.
- AuthService의 signIn 함수는 ID와 Password를 검증하고 검증 실패 시 예외를 발생시킵니다.
- 검증에 통과 했다면 JWT Claim에 id, username, 권한 등을 담아 액세스 토큰과 리프레쉬 토큰을 발급해 DTO에 담아 AuthController로 반환해줍니다.
- 이 과정에서 리프레쉬 토큰은 사용자의 id와 함께 Redis에 저장해줍니다.
- AuthController는 AuthService로부터 반환 받은 응답 DTO에 담긴 토큰을 쿠키에 담아 응답합니다.
AuthController, AuthService 코드
- AuthController
public ApiResponse<SignInResponse> signIn(HttpServletRequest request, HttpServletResponse response, @RequestBody @Valid SignInRequest signInRequest) { SignInResponse signInResponse = this.authService.signIn( signInRequest.username(), signInRequest.password() ); signInResponse.jwtAuthenticationToken().setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(signInResponse.jwtAuthenticationToken()); JwtToken accessToken = signInResponse.accessToken(); JwtToken refreshToken = signInResponse.refreshToken(); ResponseCookie accessTokenCookie = createCookie(accessToken.header(), accessToken.token(), refreshToken.expirySeconds()); ResponseCookie refreshTokenCookie = createCookie(refreshToken.header(), refreshToken.token(), refreshToken.expirySeconds()); response.setHeader(SET_COOKIE, accessTokenCookie.toString()); response.addHeader(SET_COOKIE, refreshTokenCookie.toString()); return new ApiResponse<>(signInResponse); }
- AuthService(일부 비즈니스 로직 생략)
public SignInResponse signIn(String username, String password) { UserResponse foundUser; try { foundUser = userService.findByUsername(username); } catch (EntityNotFoundException e) { throw new BusinessException(ErrorCode.AUTHENTICATION_FAILED, MessageFormat.format("username : {0} not found", username)); } if (!passwordEncoder.matches(password, foundUser.password())) { throw new BusinessException(ErrorCode.AUTHENTICATION_FAILED, MessageFormat.format("Password = {0}", password)); } Jwt.Claims claims = Jwt.Claims.builder() .userId(foundUser.id()) .roles(new String[] {String.valueOf(Role.USER)}) .username(foundUser.username()) .build(); String accessToken = jwt.generateAccessToken(claims); String refreshToken = jwt.generateRefreshToken(); int expirySeconds = jwt.getExpirySeconds(); JwtAuthentication authentication = new JwtAuthentication(accessToken, foundUser.id(), foundUser.username(), foundUser.email()); JwtAuthenticationToken authenticationToken = new JwtAuthenticationToken(authentication, null, jwt.getAuthorities(jwt.verify(accessToken))); tokenService.save(foundUser.id(), refreshToken, (long)expirySeconds); return new SignInResponse( new JwtToken(jwt.accessTokenProperties().header(), accessToken, jwt.accessTokenProperties().expirySeconds()), new JwtToken(jwt.refreshTokenProperties().header(), refreshToken, jwt.refreshTokenProperties().expirySeconds()), authenticationToken ); }
최초 인증 이후의 인증 방식
signin 엔드 포인트는 ignoring 처리가 되어있어 filter를 타지 않습니다. 그 외의 인증이 필요한 곳에서는 Filter를 타게되는데, 인증이 필요한 모든 요청은 커스텀해 만든 JwtAuthenticationFilter를 거치도록 만듭니다.
- 사용자는 요청을 보내면 JwtAuthenticationFilter를 거치게 됩니다.
- 먼저 쿠키에 AccessToken이 있는지 확인합니다. 없다면 예외를 발생 시킵니다.
- 그 후 액세스 토큰을 검증 합니다.
- 만료되었는지, 위변조되진 않았는지 확인합니다.
- 검증에 통과한다면 인증 객체를 생성하고 시큐리티 컨텍스트에 설정해줍니다.
코드
// 2. 먼저 쿠키에 AccessToken이 있는지 확인합니다. 없다면 예외를 발생 시킵니다. private String getAccessToken(HttpServletRequest request) { if (request.getCookies() == null) { throw new JwtAccessTokenNotFoundException("AccessToken is not found."); } return Arrays.stream(request.getCookies()) .filter(cookie -> cookie.getName().equals(jwt.accessTokenProperties().header())) .findFirst() .map(Cookie::getValue) .orElseThrow(() -> new JwtAccessTokenNotFoundException("AccessToken is not found")); } // 3. 그 후 액세스 토큰을 검증합니다. 만료되었는지, 위변조되진 않았는지 확인합니다. // 4. 검증에 통과한다면 인증 객체를 생성하고 시큐리티 컨텍스트에 설정해줍니다. private void authenticate(String accessToken, HttpServletRequest request, HttpServletResponse response) { try { Jwt.Claims claims = verify(accessToken); JwtAuthenticationToken authentication = createAuthenticationToken(claims, request, accessToken); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (TokenExpiredException exception) { log.warn(exception.getMessage()); refreshAuthentication(accessToken, request, response); } catch (JWTVerificationException exception) { log.warn(exception.getMessage()); } }
액세스 토큰이 만료되었을 시
액세스 토큰 검증 단계에서 만료되었을 시 RefreshToken을 통해 액세스 토큰을 재발급 받는 과정이 필요합니다.
- 사용자가 액세스 토큰과 함께 요청을 보냈지만 이 액세스 토큰이 만료가 되었습니다.
- 토큰 검증단계에서 토큰 만료 예외를 발생시킵니다.
- 토큰 만료예외가 발생하면 refershAuthentication(액세스 토큰 재발급)을 수행합니다.
- 쿠키로부터 RefreshToken을 가져옵니다. 이때 RefreshToken이 없다면 예외를 발생하고 더 이상의 인증 절차를 진행하지 않습니다.
- RefreshToken를 가져왔다면 토큰을 검증합니다.
- RefreshToken이 만료되거나 위변조되진 않았는지 확인합니다.
- 만료된 액세스 토큰에 담겨있고 id와 로그인 시 DB(Redis)에 RefreshToken과 함께 저장했던 id가 같은지 확인합니다.
- 기존의 만료된 쿠키에 들어있던 Claim을 토대로 새로운 액세스 토큰을 재발급 받습니다.
- 인증 객체를 생성하고 시큐리티 컨텍스트에 설정해줍니다.
- 재발급 받은 액세스 토큰을 쿠키에 담아주고 응답합니다.
// 3. 토큰 만료예외가 발생하면 refershAuthentication(액세스 토큰 재발급)을 수행합니다. private void authenticate(String accessToken, HttpServletRequest request, HttpServletResponse response) { try { Jwt.Claims claims = verify(accessToken); JwtAuthenticationToken authentication = createAuthenticationToken(claims, request, accessToken); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (TokenExpiredException exception) { log.warn(exception.getMessage()); refreshAuthentication(accessToken, request, response); } catch (JWTVerificationException exception) { log.warn(exception.getMessage()); } } private void refreshAuthentication(String accessToken, HttpServletRequest request, HttpServletResponse response) { try { String refreshToken = getRefreshToken(request); verifyRefreshToken(accessToken, refreshToken); String reIssuedAccessToken = accessTokenReIssue(accessToken); Jwt.Claims reIssuedClaims = verify(reIssuedAccessToken); JwtAuthenticationToken authentication = createAuthenticationToken(reIssuedClaims, request, reIssuedAccessToken); SecurityContextHolder.getContext().setAuthentication(authentication); ResponseCookie cookie = ResponseCookie.from(jwt.accessTokenProperties().header(), reIssuedAccessToken) .path("/") .httpOnly(true) .sameSite(cookieConfigProperties.sameSite().attributeValue()) .domain(cookieConfigProperties.domain()) .secure(cookieConfigProperties.secure()) .maxAge(jwt.refreshTokenProperties().expirySeconds()) .build(); response.addHeader(SET_COOKIE, cookie.toString()); } catch (EntityNotFoundException | JwtTokenNotFoundException | JWTVerificationException e) { log.warn(e.getMessage()); } } // 4. 쿠키로부터 RefreshToken을 가져옵니다. 이때 RefreshToken이 없다면 예외를 발생하고 더 이상의 인증 절차를 진행하지 않습니다. private String getRefreshToken(HttpServletRequest request) { if (request.getCookies() != null) { return Arrays.stream(request.getCookies()) .filter(cookie -> cookie.getName().equals(jwt.refreshTokenProperties().header())) .findFirst() .map(Cookie::getValue) .orElseThrow(() -> new JwtRefreshTokenNotFoundException("RefreshToken is not found.")); } else { throw new JwtRefreshTokenNotFoundException(); } } private void verifyRefreshToken(String accessToken, String refreshToken) { // 5. RefreshToken를 가져왔다면 토큰을 검증합니다. // a. RefreshToken이 만료되거나 위변조되진 않았는지 확인합니다. jwt.verify(refreshToken); // b. 만료된 액세스 토큰에 담겨있고 id와 로그인 시 DB(Redis)에 RefreshToken과 함께 저장했던 id가 같은지 확인합니다. Long userId = jwt.decode(accessToken).userId; TokenResponse token = tokenService.findByUserId(userId); if (!userId.equals(token.userId())) { throw new JWTVerificationException("Invalid refresh token."); } } // 6. 기존의 만료된 쿠키에 들어있던 Claim을 토대로 새로운 액세스 토큰을 재발급 받습니다. private String accessTokenReIssue(String accessToken) { return jwt.generateAccessToken(jwt.decode(accessToken)); }
private void refreshAuthentication(String accessToken, HttpServletRequest request, HttpServletResponse response) { try { String refreshToken = getRefreshToken(request); verifyRefreshToken(accessToken, refreshToken); String reIssuedAccessToken = accessTokenReIssue(accessToken); Jwt.Claims reIssuedClaims = verify(reIssuedAccessToken); JwtAuthenticationToken authentication = createAuthenticationToken(reIssuedClaims, request, reIssuedAccessToken); // 7. 인증 객체를 생성하고 시큐리티 컨텍스트에 설정해줍니다. SecurityContextHolder.getContext().setAuthentication(authentication); ResponseCookie cookie = ResponseCookie.from(jwt.accessTokenProperties().header(), reIssuedAccessToken) .path("/") .httpOnly(true) .sameSite(cookieConfigProperties.sameSite().attributeValue()) .domain(cookieConfigProperties.domain()) .secure(cookieConfigProperties.secure()) .maxAge(jwt.refreshTokenProperties().expirySeconds()) .build(); // 8. 재발급 받은 액세스 토큰을 쿠키에 담아주고 응답합니다. response.addHeader(SET_COOKIE, cookie.toString()); } catch (EntityNotFoundException | JwtTokenNotFoundException | JWTVerificationException e) { log.warn(e.getMessage()); } }
액세스 토큰과 리프레쉬 토큰이 모두 만료되었을 시
액세스 토큰을 재발급 받을 수 없습니다. JWTAuthenticationFIlter에서는 아무것도 할 수 없으며 API 서버는 401 응답을 내어줄 것이고 프론트에서는 로그인 페이지로 이동하게 될 것 입니다.