Project : Login/ Logout With JWT + Cookie, JWT doesn't have logout Mechanism, if so, how?

#Foreword

This document explains why I decided to use a cookie with JWT(JSON Web Token) for implementing the Logout API. During the design process, we chose to use JWT instead of Sessions through the agreement with the frontend team, eliminating the need for server-side storage(e.g., DB or Redis).

Implementing a Logout API with JWT required careful consideration among three possible approaches due to the stateless and client-side dependent nature of JWT.

These characteristics mean JWT does not have a built-in mechanism for the logout process. Let's dive into how I made a reasonable decision to solve this problem.


#3 possible approaches for Logout API with JWT

  • Characteristics of JWT

As mentioned above, JWT does not have a built-in mechanism to implement logout due to the following characteristics:

a. Stateless

: A stateless server does not store any session information, which means every request from client must contain all the necessary data for the server to fulfill the request.

b. Client-dependent

: JWT is stored on the client-side, such as in cookies or local storage.

Because of these characteristics, once a JWT is issued, it remains valid until it expires.

  1. Token Blacklisting

: Token Blacklisting involves maintaining a list of tokens that have been logged out. When a user logs out, their token is added to the blacklist. Each request needs to check this blacklist to ensure the token is still valid. This can be implemented using a database or an in-memory store like Redis.

Pros & Cons

Pros

  • Invalidates Tokens immediately upon logout.

  • Enhances Security by ensuring logged-out tokens cannot be reused.

Cons

  • Requires additional storage managment

  • Increases overhead by check blacklist on every request.

  • Adds complexity in managing a large number of tokens.

  1. Short-lived Tokens & Token Rotation

a. Shoft-lived Tokens ->"Set a very short validity period".

  • Shot-lived tokens have a very short validity period. When a user logs out, the current token might still be valid for a short period until it expires. After it expires, Token cannot be used anymore.

b. Token Rotation ->"Involves issuing new tokens periodically to replace old ones."

  • Token rotation involves issuing new tokens periodically to replace old ones.

c. Pros & Cons

Pros

  • It allows precise control over token validity.

Cons

  • It does not completely prevent token use during that small time frame before the token expires.
  • Store the JWT in a cookie, you can clear the cookie on logout. This does not invalidate the token on the server-side but ensures it is removed from the client side.

Pros & Cons

Pros

  • Very Intuitive Implementation

    : Generates a Cookie With JWT (AccessToken) -> Login

    : Expires the Cookie With JWT-> Logout

  • Suitable for cooperation with the frontend Team (FE)

  • Well integrated into web environments where the cookies are commonly used.

Cons

  • If the token is intercepted, it reduces security.

  • It is dependent on client-side requests.

#Conclusion : My decision is Cookie-Based Logout

Although blacklisting can effectively invalidate tokens and provide precise control, it requires checking the blacklist on every request to ensure token validity.

Additionally, it forces us to use server-side storag in situations where we don't use sessions, which I found to be inefficient.

Short-lived tokens and token rotation can enhance security, but they are likely to affect user experience.

Consequently, although cookie-based logout has security weaknesses and depends on the client side, it is very intuitive implementation and reduces management complexity. Additionally, it facilitates easy agreement with the frontend team, making it suitable choice for this team project.


#Summary Table:

ApproachProsCons
Token BlacklistingEffective immediate invalidation, precise controlRequires server-side storage, impacts performance (overhead), adds complexity
Short-lived TokensReduces a small time frame of vulnerability, simplifies token invalidationIncreases token exchanges, can affect user experience with expiration time
Token RotationEnhances security, limits damage from stolen tokensRequires careful token management, adds complexity
Cookie-based LogoutSimple to implement, integrates well with web environmentsDoes not prevent intercepted token use, relies on client to clear cookie

#Login/Logout Code Implementation

Auth

AuthController

package com.FC.SharedOfficePlatform.domain.auth.controller;

import com.FC.SharedOfficePlatform.domain.auth.dto.TokenDTO;
import com.FC.SharedOfficePlatform.domain.auth.dto.request.LoginRequest;
import com.FC.SharedOfficePlatform.domain.auth.service.AuthService;
import com.FC.SharedOfficePlatform.domain.member.entity.Member;
import com.FC.SharedOfficePlatform.global.security.CookieUtils;
import com.FC.SharedOfficePlatform.global.util.ResponseDTO;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

    public static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken";

    private final CookieUtils cookieUtils;

    private final AuthService authService;

    @PostMapping("/login")
    public ResponseEntity<ResponseDTO<Void>> login(
        @RequestBody @Valid LoginRequest request,
        HttpServletResponse httpServletResponse
    ) {
        Member member = authService.findByEmail(request.email());
        TokenDTO tokenDTO = authService.login(member, request);

        Cookie accessToken = cookieUtils.makeCookie(
            ACCESS_TOKEN_COOKIE_NAME, tokenDTO.accessToken()
        );
        httpServletResponse.addCookie(accessToken);

        return ResponseEntity.ok(ResponseDTO.ok());
    }

    @PostMapping("/logout")
    public ResponseEntity<ResponseDTO<Void>> logout(HttpServletResponse httpServletResponse) {
        Cookie expiredCookie = cookieUtils.expireCookie(ACCESS_TOKEN_COOKIE_NAME);
        httpServletResponse.addCookie(expiredCookie);

        return ResponseEntity.ok(ResponseDTO.ok());
    }
}

AuthService

package com.FC.SharedOfficePlatform.domain.auth.service;

import com.FC.SharedOfficePlatform.domain.auth.dto.TokenDTO;
import com.FC.SharedOfficePlatform.domain.auth.dto.request.LoginRequest;
import com.FC.SharedOfficePlatform.domain.auth.exception.InvalidPasswordException;
import com.FC.SharedOfficePlatform.domain.auth.exception.MemberNotFoundException;
import com.FC.SharedOfficePlatform.domain.member.entity.Member;
import com.FC.SharedOfficePlatform.domain.member.repository.MemberRepository;
import com.FC.SharedOfficePlatform.global.security.jwt.JwtProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class AuthService {

    private final JwtProvider jwtProvider;
    private final PasswordEncoder passwordEncoder;
    private final MemberRepository memberRepository;

    public TokenDTO login(Member member, LoginRequest loginRequest) {
        if (!passwordEncoder.matches(loginRequest.password(), member.getPassword())) {
            throw new InvalidPasswordException();
        }
        String accessToken = jwtProvider.createToken(member);
        return new TokenDTO(accessToken);
    }

    public Member findByEmail(String email) {
        Member member = memberRepository.findByEmail(email)
            .orElseThrow(MemberNotFoundException::new);
        return member;
    }
}


}

TokenDTO

public record TokenDTO(
    String accessToken
) {
}

LoginRequest

package com.FC.SharedOfficePlatform.domain.auth.dto.request;

import jakarta.validation.constraints.NotNull;

public record LoginRequest(
    @NotNull(message = "email은 필수값입니다.")
    String email,

    @NotNull(message = "password는 필수값입니다.")
    String password
) {

}

CookieUtils

package com.FC.SharedOfficePlatform.global.security;

import jakarta.servlet.http.Cookie;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class CookieUtils {

    @Value("${cookie.domain}")
    private String domain;

    public Cookie makeCookie(String name, String value) {
        Cookie cookie = new Cookie(name, value);
        cookie.setPath("/");
        cookie.setAttribute("SameSite", "Strict");
        cookie.setDomain(domain);
        cookie.setSecure(true);
        cookie.setHttpOnly(true);

        return cookie;
    }

    public Cookie expireCookie(String name) {
        Cookie cookie = makeCookie(name, "");
        cookie.setMaxAge(0);
        return cookie;
    }
}

JwtProvider

package com.FC.SharedOfficePlatform.global.security.jwt;

import com.FC.SharedOfficePlatform.domain.member.entity.Member;
import com.FC.SharedOfficePlatform.global.security.CustomMemberDetails;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.stereotype.Component;

@Component
@Slf4j
public class JwtProvider {

    private static final String AUTHORITIES_KEY = "auth";
    private final String secret;
    private final long tokenValidityInMilliseconds;
    private Key key;

    public JwtProvider(
        @Value("${jwt.secret}") String secret,
        @Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds
    ) {
        this.secret = secret;
        this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
    }

    @PostConstruct
    public void postConstruct() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public String createToken(Member member) {
        String authorities = member.getRole().name();

        long now = (new Date()).getTime();
        Date validity = new Date(now + this.tokenValidityInMilliseconds);

        return Jwts.builder()
            .setSubject(String.valueOf(member.getId()))
            .claim(AUTHORITIES_KEY, authorities)
            .claim("email", member.getEmail())
            .signWith(key, SignatureAlgorithm.HS512)
            .setExpiration(validity)
            .compact();
    }
    public Authentication getAuthentication(String token) {
        Claims claims = Jwts
            .parserBuilder()
            .setSigningKey(key)
            .build()
            .parseClaimsJws(token)
            .getBody();

        Collection<? extends GrantedAuthority> authorities =
            Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());

        CustomMemberDetails userDetails = CustomMemberDetails.builder()
            .id(Long.parseLong(claims.getSubject()))
            .email(claims.get("email").toString())
            .password("") // Password is not included in the token for security reasons
            .authorities(authorities)
            .build();

        /*
        Above(userDetails) can be replaced with below line of code(userDetails) With MemberDetails DI
        MemberDetails userDetails = memberDetailsService.loadUserById(Long.parseLong(claims.getSubject()));
         */

        return new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }
}

JwtFilter

package com.FC.SharedOfficePlatform.global.security.jwt;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import java.io.IOException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;

@Component
@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends GenericFilterBean {

    private static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken";

    private final JwtProvider jwtProvider;

    @Override
    public void doFilter(
        ServletRequest request, ServletResponse response, FilterChain chain
    ) throws IOException, ServletException {

        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String requestURI = httpServletRequest.getRequestURI();
        String accessToken = resolveToken(httpServletRequest);
        if (accessToken == null) {
            chain.doFilter(request, response);
            return;
        }
        // If the token is valid, it retrieves the authentication object and sets it in the SecurityContext.
        if (StringUtils.hasText(accessToken) && jwtProvider.validateToken(accessToken)) {
            Authentication authentication = jwtProvider.getAuthentication(accessToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            log.info(
                "Security Context에 'memberId: {}' 인증 정보를 저장했습니다, uri: {}",
                authentication.getName(), requestURI
            );
        } else {
            log.info("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
        }
        chain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies == null) return null;

        //iterates through them to find the cookie named accessToken, Returns the value of the accessToken cookie if found.
        for (Cookie cookie : cookies) {
            if (ACCESS_TOKEN_COOKIE_NAME.equals(cookie.getName())) {
                return cookie.getValue();
            }
        }
        return null;
    }
}

Security Snapshot Where JwtFilter is used.