Skip to content

Split View: Spring Boot 인증 실전 가이드 — 세션, JWT, SecurityContext, 쿠키 기반 인증 완전 정복

✨ Learn with Quiz
|

Spring Boot 인증 실전 가이드 — 세션, JWT, SecurityContext, 쿠키 기반 인증 완전 정복

📚 SSO 쿠키/JWT 인증 시리즈 > 인덱스 ← 현재: Spring Boot 편 → Django 편

개요 — Spring Boot 인증 아키텍처

Spring Security는 서블릿 필터 체인(Servlet Filter Chain) 위에 보안 로직을 쌓는 구조입니다. HTTP 요청이 컨트롤러에 도달하기 전에 여러 보안 필터를 거치며, 인증(Authentication)과 인가(Authorization)가 이루어집니다.

HTTP 요청
DelegatingFilterProxy
FilterChainProxy
SecurityFilterChain
  ├── CorsFilter
  ├── CsrfFilter
  ├── UsernamePasswordAuthenticationFilter  (세션 기반)
  ├── JwtAuthenticationFilter               (커스텀 JWT 필터)
  ├── ExceptionTranslationFilter
  └── AuthorizationFilter
DispatcherServletController

핵심 흐름은 다음과 같습니다.

  1. SecurityFilterChain — 어떤 요청에 어떤 필터를 적용할지 결정
  2. AuthenticationManager — 인증 처리를 위임받는 관리자
  3. AuthenticationProvider — 실제 인증 로직 (DB 조회, 비밀번호 검증 등)
  4. SecurityContext — 인증된 사용자 정보를 스레드 로컬에 저장
// 기본 구조 이해
SecurityContextHolder
  └── SecurityContext
        └── Authentication
              ├── Principal   (사용자 정보)
              ├── Credentials (비밀번호/토큰)
              └── Authorities (권한 목록)

세션 기반 인증

가장 전통적인 방식입니다. 서버가 HttpSession을 생성하고, 클라이언트에게 JSESSIONID 쿠키를 발급합니다.

로그인 핸들러 구현

@RestController
@RequestMapping("/api/auth")
public class SessionAuthController {

    private final AuthenticationManager authenticationManager;

    public SessionAuthController(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request,
                                   HttpServletRequest httpRequest) {
        // 1. 인증 시도
        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                request.getUsername(),
                request.getPassword()
            )
        );

        // 2. SecurityContext에 저장
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // 3. 세션 생성 (JSESSIONID 쿠키 자동 발급)
        HttpSession session = httpRequest.getSession(true);
        session.setAttribute(
            HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
            SecurityContextHolder.getContext()
        );

        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        return ResponseEntity.ok(Map.of(
            "username", userDetails.getUsername(),
            "roles", userDetails.getAuthorities()
        ));
    }

    @PostMapping("/logout")
    public ResponseEntity<?> logout(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.invalidate(); // 세션 무효화
        }
        SecurityContextHolder.clearContext();
        return ResponseEntity.ok(Map.of("message", "로그아웃 성공"));
    }
}

SecurityConfig (세션 기반)

@Configuration
@EnableWebSecurity
public class SessionSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.csrfTokenRepository(
                CookieCsrfTokenRepository.withHttpOnlyFalse()))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/login", "/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                .maximumSessions(1)                    // 동시 세션 제한
                .maxSessionsPreventsLogin(false)       // 기존 세션 만료
            );

        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

세션 방식은 구현이 간단하지만, 서버 스케일 아웃 시 세션 공유 문제가 발생합니다. Redis 기반 Spring Session으로 해결할 수 있지만 복잡도가 올라갑니다.


JWT 기반 인증

JWT 토큰 생성

io.jsonwebtoken (jjwt) 라이브러리를 사용합니다. build.gradle에 의존성을 추가합니다.

// build.gradle
dependencies {
    implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
}
@Component
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String secretKey;

    @Value("${jwt.access-token-expiration}")
    private long accessTokenExpiration;   // e.g. 900000 (15분)

    @Value("${jwt.refresh-token-expiration}")
    private long refreshTokenExpiration;  // e.g. 604800000 (7일)

    private SecretKey key;

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

    /** Access Token 생성 */
    public String generateAccessToken(UserDetails userDetails, String tenantId) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("roles", userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()));
        claims.put("tenant_id", tenantId);
        claims.put("token_type", "access");

        return Jwts.builder()
                .claims(claims)
                .subject(userDetails.getUsername())
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + accessTokenExpiration))
                .signWith(key)
                .compact();
    }

    /** Refresh Token 생성 */
    public String generateRefreshToken(UserDetails userDetails) {
        return Jwts.builder()
                .subject(userDetails.getUsername())
                .claim("token_type", "refresh")
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + refreshTokenExpiration))
                .signWith(key)
                .compact();
    }

    /** 토큰에서 Claims 추출 */
    public Claims extractAllClaims(String token) {
        return Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    /** 토큰에서 username 추출 */
    public String extractUsername(String token) {
        return extractAllClaims(token).getSubject();
    }

    /** 토큰 유효성 검증 */
    public boolean isTokenValid(String token, UserDetails userDetails) {
        try {
            Claims claims = extractAllClaims(token);
            String username = claims.getSubject();
            Date expiration = claims.getExpiration();
            return username.equals(userDetails.getUsername())
                    && expiration.after(new Date());
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }
}

JWT 필터 구현

OncePerRequestFilter를 상속하여 요청마다 한 번씩 JWT를 검증합니다. Authorization 헤더HttpOnly 쿠키 두 가지 방식을 모두 지원합니다.

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserDetailsService userDetailsService;
    private final TokenBlacklistService tokenBlacklistService;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider,
                                   UserDetailsService userDetailsService,
                                   TokenBlacklistService tokenBlacklistService) {
        this.jwtTokenProvider = jwtTokenProvider;
        this.userDetailsService = userDetailsService;
        this.tokenBlacklistService = tokenBlacklistService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        String token = resolveToken(request);

        if (token != null && !tokenBlacklistService.isBlacklisted(token)) {
            try {
                String username = jwtTokenProvider.extractUsername(token);

                if (username != null &&
                    SecurityContextHolder.getContext().getAuthentication() == null) {

                    UserDetails userDetails =
                        userDetailsService.loadUserByUsername(username);

                    if (jwtTokenProvider.isTokenValid(token, userDetails)) {
                        UsernamePasswordAuthenticationToken authToken =
                            new UsernamePasswordAuthenticationToken(
                                userDetails,
                                null,
                                userDetails.getAuthorities()
                            );
                        authToken.setDetails(
                            new WebAuthenticationDetailsSource()
                                .buildDetails(request)
                        );

                        SecurityContextHolder.getContext()
                            .setAuthentication(authToken);
                    }
                }
            } catch (JwtException e) {
                // 유효하지 않은 토큰 — 인증 없이 다음 필터로
                logger.warn("JWT 검증 실패: " + e.getMessage());
            }
        }

        filterChain.doFilter(request, response);
    }

    /**
     * 토큰 추출: Authorization 헤더 우선, 없으면 쿠키에서 읽기
     */
    private String resolveToken(HttpServletRequest request) {
        // 1) Authorization: Bearer <token>
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }

        // 2) HttpOnly Cookie: access_token=<token>
        if (request.getCookies() != null) {
            return Arrays.stream(request.getCookies())
                    .filter(c -> "access_token".equals(c.getName()))
                    .findFirst()
                    .map(Cookie::getValue)
                    .orElse(null);
        }

        return null;
    }
}

SecurityFilterChain 설정 (JWT)

@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // @PreAuthorize, @Secured 활성화
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final AuthenticationProvider authenticationProvider;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter,
                          AuthenticationProvider authenticationProvider) {
        this.jwtAuthFilter = jwtAuthFilter;
        this.authenticationProvider = authenticationProvider;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // CSRF: JWT + SameSite 쿠키 사용 시 비활성화 가능
            .csrf(AbstractHttpConfigurer::disable)

            // CORS 설정
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))

            // 세션을 사용하지 않음 (STATELESS)
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            // URL별 인가 규칙
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(
                    "/api/auth/login",
                    "/api/auth/register",
                    "/api/auth/refresh",
                    "/api/public/**",
                    "/actuator/health"
                ).permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/tenant/**").hasAnyRole("ADMIN", "TENANT_ADMIN")
                .anyRequest().authenticated()
            )

            // AuthenticationProvider 등록
            .authenticationProvider(authenticationProvider)

            // JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 배치
            .addFilterBefore(jwtAuthFilter,
                UsernamePasswordAuthenticationFilter.class)

            // 인증 실패 핸들링
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint((request, response, authException) -> {
                    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                    response.setContentType("application/json;charset=UTF-8");
                    response.getWriter().write(
                        "{\"error\":\"인증이 필요합니다\",\"status\":401}");
                })
                .accessDeniedHandler((request, response, accessDeniedException) -> {
                    response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                    response.setContentType("application/json;charset=UTF-8");
                    response.getWriter().write(
                        "{\"error\":\"접근 권한이 없습니다\",\"status\":403}");
                })
            );

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of(
            "https://app.example.com",
            "https://admin.example.com"
        ));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("*"));
        config.setAllowCredentials(true); // 쿠키 전송 허용
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}

쿠키 설정 실전

JWT를 쿠키에 담아 전송하면 프론트엔드에서 토큰을 관리할 필요가 없습니다. HttpOnly + Secure + SameSite 조합이 핵심입니다.

쿠키 생성 유틸

@Component
public class CookieUtil {

    @Value("${app.cookie.domain}")
    private String cookieDomain;  // e.g. ".example.com"

    @Value("${app.cookie.secure}")
    private boolean secure;       // 운영: true, 로컬: false

    /**
     * Access Token 쿠키 생성
     */
    public ResponseCookie createAccessTokenCookie(String token) {
        return ResponseCookie.from("access_token", token)
                .httpOnly(true)           // JS 접근 차단 (XSS 방어)
                .secure(secure)           // HTTPS에서만 전송
                .sameSite("Lax")          // CSRF 기본 방어
                .domain(cookieDomain)     // 서브도메인 공유
                .path("/")                // 모든 경로
                .maxAge(Duration.ofMinutes(15))
                .build();
    }

    /**
     * Refresh Token 쿠키 생성
     */
    public ResponseCookie createRefreshTokenCookie(String token) {
        return ResponseCookie.from("refresh_token", token)
                .httpOnly(true)
                .secure(secure)
                .sameSite("Strict")       // Refresh는 더 엄격하게
                .domain(cookieDomain)
                .path("/api/auth/refresh") // refresh 엔드포인트에서만 전송
                .maxAge(Duration.ofDays(7))
                .build();
    }

    /**
     * 쿠키 삭제 (maxAge=0)
     */
    public ResponseCookie deleteAccessTokenCookie() {
        return ResponseCookie.from("access_token", "")
                .httpOnly(true)
                .secure(secure)
                .sameSite("Lax")
                .domain(cookieDomain)
                .path("/")
                .maxAge(0)    // 즉시 삭제
                .build();
    }

    public ResponseCookie deleteRefreshTokenCookie() {
        return ResponseCookie.from("refresh_token", "")
                .httpOnly(true)
                .secure(secure)
                .sameSite("Strict")
                .domain(cookieDomain)
                .path("/api/auth/refresh")
                .maxAge(0)
                .build();
    }
}

로그인 응답에서 쿠키 설정

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

    private final AuthenticationManager authManager;
    private final JwtTokenProvider jwtTokenProvider;
    private final CookieUtil cookieUtil;

    // Constructor 생략

    @PostMapping("/login")
    public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request) {
        Authentication auth = authManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                request.getUsername(), request.getPassword()));

        UserDetails userDetails = (UserDetails) auth.getPrincipal();
        String tenantId = ((CustomUserDetails) userDetails).getTenantId();

        String accessToken = jwtTokenProvider.generateAccessToken(userDetails, tenantId);
        String refreshToken = jwtTokenProvider.generateRefreshToken(userDetails);

        ResponseCookie accessCookie = cookieUtil.createAccessTokenCookie(accessToken);
        ResponseCookie refreshCookie = cookieUtil.createRefreshTokenCookie(refreshToken);

        return ResponseEntity.ok()
                .header(HttpHeaders.SET_COOKIE, accessCookie.toString())
                .header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
                .body(Map.of(
                    "message", "로그인 성공",
                    "username", userDetails.getUsername(),
                    "roles", userDetails.getAuthorities()
                ));
    }
}

Claim 파싱 및 사용자 정보 접근

Custom UserDetails

@Getter
public class CustomUserDetails implements UserDetails {

    private final Long id;
    private final String username;
    private final String password;
    private final String tenantId;
    private final Collection<? extends GrantedAuthority> authorities;

    public CustomUserDetails(User user) {
        this.id = user.getId();
        this.username = user.getUsername();
        this.password = user.getPassword();
        this.tenantId = user.getTenantId();
        this.authorities = user.getRoles().stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
                .collect(Collectors.toList());
    }
}

Controller에서 사용자 정보 접근

@RestController
@RequestMapping("/api/users")
public class UserController {

    // 방법 1: SecurityContextHolder 직접 사용
    @GetMapping("/me")
    public ResponseEntity<?> getCurrentUser() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        CustomUserDetails user = (CustomUserDetails) auth.getPrincipal();

        return ResponseEntity.ok(Map.of(
            "id", user.getId(),
            "username", user.getUsername(),
            "tenantId", user.getTenantId(),
            "roles", user.getAuthorities()
        ));
    }

    // 방법 2: @AuthenticationPrincipal 어노테이션 (권장)
    @GetMapping("/profile")
    public ResponseEntity<?> getProfile(
            @AuthenticationPrincipal CustomUserDetails user) {
        return ResponseEntity.ok(Map.of(
            "id", user.getId(),
            "username", user.getUsername(),
            "tenantId", user.getTenantId()
        ));
    }

    // 방법 3: JWT Claims에서 직접 추출
    @GetMapping("/claims")
    public ResponseEntity<?> getClaimsInfo(HttpServletRequest request) {
        String token = extractTokenFromCookie(request);
        Claims claims = jwtTokenProvider.extractAllClaims(token);

        return ResponseEntity.ok(Map.of(
            "sub", claims.getSubject(),
            "roles", claims.get("roles"),
            "tenant_id", claims.get("tenant_id"),
            "exp", claims.getExpiration(),
            "iat", claims.getIssuedAt()
        ));
    }

    // 방법 4: @PreAuthorize로 메서드 수준 인가
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin/all")
    public ResponseEntity<?> getAllUsers() {
        // ADMIN만 접근 가능
        return ResponseEntity.ok(userService.findAll());
    }

    @PreAuthorize("#userId == authentication.principal.id")
    @PutMapping("/{userId}")
    public ResponseEntity<?> updateUser(@PathVariable Long userId,
                                         @RequestBody UpdateRequest req) {
        // 자기 자신만 수정 가능
        return ResponseEntity.ok(userService.update(userId, req));
    }
}

브라우저 저장소별 접근 가능성 표

저장소JS 접근서버 자동 전송XSS 취약CSRF 취약
localStorageOXO (위험)X
sessionStorageOXO (위험)X
일반 CookieOO (같은 도메인)OO (위험)
HttpOnly CookieX (안전)O (같은 도메인)X (안전)O (SameSite로 완화)
HttpOnly + SameSite=StrictX같은 사이트만XX (안전)
HttpOnly + SameSite=LaxXGET 크로스사이트 허용X거의 안전

권장 조합: HttpOnly + Secure + SameSite=Lax (Access Token), HttpOnly + Secure + SameSite=Strict (Refresh Token)


CORS + Credential 전송 설정

프론트엔드와 백엔드 도메인이 다를 때 쿠키를 전송하려면 CORS + credentials 설정이 필수입니다.

Spring Boot CORS 설정

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();

    // allowedOrigins에 * 사용 불가 (credentials: true일 때)
    config.setAllowedOrigins(List.of(
        "https://app.example.com",
        "https://admin.example.com"
    ));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
    config.setAllowedHeaders(List.of(
        "Authorization", "Content-Type", "X-Requested-With", "Accept"));
    config.setExposedHeaders(List.of("Set-Cookie"));
    config.setAllowCredentials(true);  // 반드시 true
    config.setMaxAge(3600L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);
    return source;
}

프론트엔드 (fetch / axios)

// fetch
const res = await fetch('https://api.example.com/api/users/me', {
  method: 'GET',
  credentials: 'include', // 쿠키 전송 필수
})

// axios 전역 설정
axios.defaults.withCredentials = true

주의: allowCredentials(true) 사용 시 allowedOrigins"*" 와일드카드를 사용할 수 없습니다. 반드시 명시적으로 도메인을 나열해야 합니다.


로그아웃 및 토큰 무효화

JWT는 서버에서 상태를 저장하지 않기 때문에 "무효화"가 어렵습니다. Redis 기반 블랙리스트가 가장 실용적인 방법입니다.

Redis 블랙리스트 서비스

@Service
public class TokenBlacklistService {

    private final RedisTemplate<String, String> redisTemplate;

    public TokenBlacklistService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 토큰을 블랙리스트에 추가 (남은 만료 시간만큼 TTL 설정)
     */
    public void blacklist(String token, long remainingMillis) {
        redisTemplate.opsForValue().set(
            "blacklist:" + token,
            "revoked",
            Duration.ofMillis(remainingMillis)
        );
    }

    /**
     * 토큰이 블랙리스트에 있는지 확인
     */
    public boolean isBlacklisted(String token) {
        return Boolean.TRUE.equals(
            redisTemplate.hasKey("blacklist:" + token));
    }
}

로그아웃 엔드포인트

@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletRequest request) {
    String token = resolveToken(request);

    if (token != null) {
        try {
            Claims claims = jwtTokenProvider.extractAllClaims(token);
            long remainingMillis =
                claims.getExpiration().getTime() - System.currentTimeMillis();

            if (remainingMillis > 0) {
                tokenBlacklistService.blacklist(token, remainingMillis);
            }
        } catch (JwtException ignored) {
            // 이미 만료된 토큰은 블랙리스트에 추가할 필요 없음
        }
    }

    // 쿠키 삭제
    ResponseCookie deleteAccess = cookieUtil.deleteAccessTokenCookie();
    ResponseCookie deleteRefresh = cookieUtil.deleteRefreshTokenCookie();

    return ResponseEntity.ok()
            .header(HttpHeaders.SET_COOKIE, deleteAccess.toString())
            .header(HttpHeaders.SET_COOKIE, deleteRefresh.toString())
            .body(Map.of("message", "로그아웃 성공"));
}

토큰 리프레시 전략

Access Token은 짧게 (15분), Refresh Token은 길게 (7일) 설정하고, Refresh Token으로 새 Access Token을 발급합니다. Refresh Token Rotation을 적용하면 탈취된 Refresh Token도 한 번만 사용 가능합니다.

Refresh 엔드포인트

@PostMapping("/refresh")
public ResponseEntity<?> refresh(HttpServletRequest request) {
    // 1. 쿠키에서 Refresh Token 추출
    String refreshToken = Arrays.stream(
            Optional.ofNullable(request.getCookies()).orElse(new Cookie[0]))
        .filter(c -> "refresh_token".equals(c.getName()))
        .findFirst()
        .map(Cookie::getValue)
        .orElseThrow(() -> new AuthException("Refresh Token이 없습니다"));

    // 2. Refresh Token 검증
    Claims claims;
    try {
        claims = jwtTokenProvider.extractAllClaims(refreshToken);
    } catch (JwtException e) {
        throw new AuthException("유효하지 않은 Refresh Token입니다");
    }

    if (!"refresh".equals(claims.get("token_type"))) {
        throw new AuthException("잘못된 토큰 타입입니다");
    }

    // 3. 블랙리스트 확인
    if (tokenBlacklistService.isBlacklisted(refreshToken)) {
        throw new AuthException("이미 사용된 Refresh Token입니다 (Rotation 위반 감지)");
    }

    // 4. 사용자 정보 로드
    String username = claims.getSubject();
    CustomUserDetails userDetails =
        (CustomUserDetails) userDetailsService.loadUserByUsername(username);

    // 5. 기존 Refresh Token 블랙리스트 처리 (Rotation)
    long remaining = claims.getExpiration().getTime() - System.currentTimeMillis();
    tokenBlacklistService.blacklist(refreshToken, remaining);

    // 6. 새 토큰 쌍 발급
    String newAccessToken = jwtTokenProvider.generateAccessToken(
        userDetails, userDetails.getTenantId());
    String newRefreshToken = jwtTokenProvider.generateRefreshToken(userDetails);

    // 7. 새 쿠키 설정
    return ResponseEntity.ok()
        .header(HttpHeaders.SET_COOKIE,
            cookieUtil.createAccessTokenCookie(newAccessToken).toString())
        .header(HttpHeaders.SET_COOKIE,
            cookieUtil.createRefreshTokenCookie(newRefreshToken).toString())
        .body(Map.of("message", "토큰 갱신 성공"));
}

Rotation 위반 감지: 이미 블랙리스트에 있는 Refresh Token이 사용되면 공격 시도로 간주하고, 해당 사용자의 모든 토큰을 무효화하는 방어 로직을 추가할 수 있습니다.


보안 트레이드오프

위협localStorage JWTHttpOnly Cookie JWT세션
XSS즉시 탈취 가능JS 접근 불가 (안전)세션 ID 접근 불가
CSRF해당 없음SameSite + CORS로 방어CSRF 토큰 필요
Token 탈취XSS로 쉽게 탈취네트워크 수준에서만 가능세션 ID 유출 시 위험
Replay 공격짧은 만료 시간으로 완화짧은 만료 + 블랙리스트서버에서 세션 무효화
확장성수평 확장 용이수평 확장 용이Redis Session 필요
서버 부담없음블랙리스트 조회세션 저장소

결론: 대부분의 경우 HttpOnly Cookie + SameSite=Lax + Secure + 짧은 만료시간 + Refresh Token Rotation이 가장 실용적인 선택입니다.


체크리스트

Spring Boot JWT 인증을 구현할 때 아래 항목을 확인하세요.

  • JWT Secret Key를 충분히 길게 설정했는가 (256비트 이상)
  • Secret Key를 환경변수/Vault에서 로드하는가 (코드에 하드코딩하지 않았는가)
  • Access Token 만료 시간이 충분히 짧은가 (15분 이하 권장)
  • Refresh Token Rotation을 적용했는가
  • HttpOnly + Secure + SameSite 쿠키 설정을 했는가
  • CORS allowCredentials(true)와 명시적 Origin을 설정했는가
  • SecurityFilterChain에서 STATELESS 세션 정책을 설정했는가
  • JWT 필터에서 예외 발생 시 안전하게 처리하는가 (500 에러 방지)
  • 로그아웃 시 쿠키 삭제 + 토큰 블랙리스트를 구현했는가
  • Token 블랙리스트 TTL을 토큰 만료 시간과 일치시켰는가
  • @PreAuthorize 또는 URL 기반 인가 규칙을 설정했는가
  • CSRF 설정이 API 특성에 맞게 구성되었는가
  • ExceptionHandling(authenticationEntryPoint, accessDeniedHandler)을 설정했는가
  • Refresh 엔드포인트가 Refresh Token만 받아들이는가 (token_type 검증)
  • 민감한 Claim (비밀번호 등)을 JWT에 포함하지 않았는가

흔한 버그와 오해

1. "CSRF를 무조건 disable 해도 된다"

JWT를 Authorization 헤더로 보낸다면 CSRF 공격 불가능 (브라우저가 자동 첨부 안 함). 그러나 쿠키에 JWT를 담는다면 SameSite 설정 없이 CSRF를 끄면 취약합니다.

2. "JWT에 민감한 정보를 넣어도 암호화되니까 안전하다"

JWT는 **서명(Signed)**이지 **암호화(Encrypted)**가 아닙니다. Base64 디코딩만 하면 누구나 Payload를 볼 수 있습니다. 비밀번호, 주민번호 등 민감 정보를 절대 넣지 마세요.

3. "SecurityContextHolder는 항상 스레드 안전하다"

기본 전략은 ThreadLocal이므로 같은 스레드 내에서는 안전합니다. 그러나 @Async, CompletableFuture, WebFlux 등 다른 스레드에서 실행되는 코드에서는 SecurityContext가 전파되지 않습니다. SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL) 또는 DelegatingSecurityContextExecutor를 사용해야 합니다.

4. "OncePerRequestFilter인데 왜 두 번 실행되나요?"

서블릿 포워딩(RequestDispatcher.forward())이나 에러 디스패치 시 필터가 다시 실행될 수 있습니다. shouldNotFilter() 메서드를 오버라이드하거나 shouldNotFilterErrorDispatch()를 확인하세요.

5. "allowedOrigins에 * 넣었는데 쿠키가 안 보내진다"

allowCredentials(true)allowedOrigins("*")는 함께 사용할 수 없습니다. 명시적으로 도메인을 나열하거나 allowedOriginPatterns("*")를 사용해야 합니다 (보안상 비추천).

6. "Refresh Token을 localStorage에 저장해도 될까?"

Refresh Token은 Access Token보다 수명이 길고, 새로운 Access Token을 발급받을 수 있는 민감한 토큰입니다. localStorage는 XSS에 취약하므로 반드시 HttpOnly 쿠키에 저장해야 합니다.

7. "JWT 만료 시간을 1년으로 설정했다"

Access Token의 만료 시간이 길면 탈취 시 피해가 커집니다. Access Token은 15분 이하, Refresh Token은 7~30일이 일반적입니다.


참고자료

  1. Spring Security Reference — Architecture — 필터 체인 아키텍처
  2. Spring Security Reference — Authentication — 인증 메커니즘
  3. Spring Security Reference — Authorization — 인가 메커니즘
  4. jjwt GitHub — Java JWT 라이브러리
  5. RFC 7519 — JSON Web Token — JWT 표준 스펙
  6. RFC 6749 — OAuth 2.0 — OAuth 2.0 프레임워크
  7. OWASP — Session Management Cheat Sheet — 세션 관리 보안
  8. OWASP — JWT Security Cheat Sheet — JWT 보안
  9. OWASP — Cross-Site Request Forgery Prevention — CSRF 방어
  10. MDN — Set-Cookie — 쿠키 헤더 스펙
  11. MDN — SameSite cookies — SameSite 속성
  12. Spring Boot + Redis Session — Redis 기반 세션 공유
  13. Baeldung — Spring Security JWT — JWT 인증 튜토리얼

Spring Boot Authentication Practical Guide — Session, JWT, SecurityContext, Cookie-Based Auth Complete Mastery

SSO Cookie/JWT Authentication Series · Index · Current: Spring Boot Edition · Django Edition

Overview — Spring Boot Authentication Architecture

Spring Security builds security logic on top of the Servlet Filter Chain. Before an HTTP request reaches the controller, it passes through multiple security filters where Authentication and Authorization take place.

HTTP Request
  |
DelegatingFilterProxy
  |
FilterChainProxy
  |
SecurityFilterChain
  |-- CorsFilter
  |-- CsrfFilter
  |-- UsernamePasswordAuthenticationFilter  (Session-based)
  |-- JwtAuthenticationFilter               (Custom JWT filter)
  |-- ExceptionTranslationFilter
  +-- AuthorizationFilter
  |
DispatcherServlet -> Controller

The core flow is as follows:

  1. SecurityFilterChain — Determines which filters to apply to which requests
  2. AuthenticationManager — Manager that receives authentication processing delegation
  3. AuthenticationProvider — Performs actual authentication logic (DB lookup, password verification, etc.)
  4. SecurityContext — Stores authenticated user information in thread-local
// Basic structure
SecurityContextHolder
  +-- SecurityContext
        +-- Authentication
              |-- Principal   (User info)
              |-- Credentials (Password/Token)
              +-- Authorities (Permission list)

Session-Based Authentication

The most traditional approach. The server creates an HttpSession and issues a JSESSIONID cookie to the client.

Login Handler Implementation

@RestController
@RequestMapping("/api/auth")
public class SessionAuthController {

    private final AuthenticationManager authenticationManager;

    public SessionAuthController(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request,
                                   HttpServletRequest httpRequest) {
        // 1. Authentication attempt
        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                request.getUsername(),
                request.getPassword()
            )
        );

        // 2. Store in SecurityContext
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // 3. Create session (JSESSIONID cookie automatically issued)
        HttpSession session = httpRequest.getSession(true);
        session.setAttribute(
            HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
            SecurityContextHolder.getContext()
        );

        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        return ResponseEntity.ok(Map.of(
            "username", userDetails.getUsername(),
            "roles", userDetails.getAuthorities()
        ));
    }

    @PostMapping("/logout")
    public ResponseEntity<?> logout(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.invalidate(); // Invalidate session
        }
        SecurityContextHolder.clearContext();
        return ResponseEntity.ok(Map.of("message", "Logout successful"));
    }
}

SecurityConfig (Session-Based)

@Configuration
@EnableWebSecurity
public class SessionSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.csrfTokenRepository(
                CookieCsrfTokenRepository.withHttpOnlyFalse()))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/login", "/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                .maximumSessions(1)                    // Concurrent session limit
                .maxSessionsPreventsLogin(false)       // Expire existing session
            );

        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

Session-based authentication is simple to implement, but session sharing issues arise when scaling out servers. This can be resolved with Redis-based Spring Session, but it increases complexity.


JWT-Based Authentication

JWT Token Generation

Uses the io.jsonwebtoken (jjwt) library. Add the dependency to build.gradle.

// build.gradle
dependencies {
    implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
}
@Component
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String secretKey;

    @Value("${jwt.access-token-expiration}")
    private long accessTokenExpiration;   // e.g. 900000 (15 min)

    @Value("${jwt.refresh-token-expiration}")
    private long refreshTokenExpiration;  // e.g. 604800000 (7 days)

    private SecretKey key;

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

    /** Generate Access Token */
    public String generateAccessToken(UserDetails userDetails, String tenantId) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("roles", userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()));
        claims.put("tenant_id", tenantId);
        claims.put("token_type", "access");

        return Jwts.builder()
                .claims(claims)
                .subject(userDetails.getUsername())
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + accessTokenExpiration))
                .signWith(key)
                .compact();
    }

    /** Generate Refresh Token */
    public String generateRefreshToken(UserDetails userDetails) {
        return Jwts.builder()
                .subject(userDetails.getUsername())
                .claim("token_type", "refresh")
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + refreshTokenExpiration))
                .signWith(key)
                .compact();
    }

    /** Extract Claims from token */
    public Claims extractAllClaims(String token) {
        return Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    /** Extract username from token */
    public String extractUsername(String token) {
        return extractAllClaims(token).getSubject();
    }

    /** Validate token */
    public boolean isTokenValid(String token, UserDetails userDetails) {
        try {
            Claims claims = extractAllClaims(token);
            String username = claims.getSubject();
            Date expiration = claims.getExpiration();
            return username.equals(userDetails.getUsername())
                    && expiration.after(new Date());
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }
}

JWT Filter Implementation

Extends OncePerRequestFilter to verify JWT once per request. Supports both Authorization header and HttpOnly cookie methods.

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserDetailsService userDetailsService;
    private final TokenBlacklistService tokenBlacklistService;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider,
                                   UserDetailsService userDetailsService,
                                   TokenBlacklistService tokenBlacklistService) {
        this.jwtTokenProvider = jwtTokenProvider;
        this.userDetailsService = userDetailsService;
        this.tokenBlacklistService = tokenBlacklistService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain)
            throws ServletException, IOException {

        String token = resolveToken(request);

        if (token != null && !tokenBlacklistService.isBlacklisted(token)) {
            try {
                String username = jwtTokenProvider.extractUsername(token);

                if (username != null &&
                    SecurityContextHolder.getContext().getAuthentication() == null) {

                    UserDetails userDetails =
                        userDetailsService.loadUserByUsername(username);

                    if (jwtTokenProvider.isTokenValid(token, userDetails)) {
                        UsernamePasswordAuthenticationToken authToken =
                            new UsernamePasswordAuthenticationToken(
                                userDetails,
                                null,
                                userDetails.getAuthorities()
                            );
                        authToken.setDetails(
                            new WebAuthenticationDetailsSource()
                                .buildDetails(request)
                        );

                        SecurityContextHolder.getContext()
                            .setAuthentication(authToken);
                    }
                }
            } catch (JwtException e) {
                // Invalid token — proceed to next filter without authentication
                logger.warn("JWT validation failed: " + e.getMessage());
            }
        }

        filterChain.doFilter(request, response);
    }

    /**
     * Token extraction: Authorization header first, then cookie
     */
    private String resolveToken(HttpServletRequest request) {
        // 1) Authorization: Bearer <token>
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }

        // 2) HttpOnly Cookie: access_token=<token>
        if (request.getCookies() != null) {
            return Arrays.stream(request.getCookies())
                    .filter(c -> "access_token".equals(c.getName()))
                    .findFirst()
                    .map(Cookie::getValue)
                    .orElse(null);
        }

        return null;
    }
}

SecurityFilterChain Configuration (JWT)

@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // Enable @PreAuthorize, @Secured
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final AuthenticationProvider authenticationProvider;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter,
                          AuthenticationProvider authenticationProvider) {
        this.jwtAuthFilter = jwtAuthFilter;
        this.authenticationProvider = authenticationProvider;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // CSRF: Can be disabled when using JWT + SameSite cookies
            .csrf(AbstractHttpConfigurer::disable)

            // CORS configuration
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))

            // Do not use sessions (STATELESS)
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            // URL-based authorization rules
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(
                    "/api/auth/login",
                    "/api/auth/register",
                    "/api/auth/refresh",
                    "/api/public/**",
                    "/actuator/health"
                ).permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/tenant/**").hasAnyRole("ADMIN", "TENANT_ADMIN")
                .anyRequest().authenticated()
            )

            // Register AuthenticationProvider
            .authenticationProvider(authenticationProvider)

            // Place JWT filter before UsernamePasswordAuthenticationFilter
            .addFilterBefore(jwtAuthFilter,
                UsernamePasswordAuthenticationFilter.class)

            // Authentication failure handling
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint((request, response, authException) -> {
                    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                    response.setContentType("application/json;charset=UTF-8");
                    response.getWriter().write(
                        "{\"error\":\"Authentication required\",\"status\":401}");
                })
                .accessDeniedHandler((request, response, accessDeniedException) -> {
                    response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                    response.setContentType("application/json;charset=UTF-8");
                    response.getWriter().write(
                        "{\"error\":\"Access denied\",\"status\":403}");
                })
            );

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of(
            "https://app.example.com",
            "https://admin.example.com"
        ));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("*"));
        config.setAllowCredentials(true); // Allow cookie transmission
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}

When JWTs are sent via cookies, the frontend does not need to manage tokens. The combination of HttpOnly + Secure + SameSite is the key.

@Component
public class CookieUtil {

    @Value("${app.cookie.domain}")
    private String cookieDomain;  // e.g. ".example.com"

    @Value("${app.cookie.secure}")
    private boolean secure;       // Production: true, Local: false

    /**
     * Create Access Token cookie
     */
    public ResponseCookie createAccessTokenCookie(String token) {
        return ResponseCookie.from("access_token", token)
                .httpOnly(true)           // Block JS access (XSS defense)
                .secure(secure)           // HTTPS only
                .sameSite("Lax")          // Basic CSRF defense
                .domain(cookieDomain)     // Subdomain sharing
                .path("/")                // All paths
                .maxAge(Duration.ofMinutes(15))
                .build();
    }

    /**
     * Create Refresh Token cookie
     */
    public ResponseCookie createRefreshTokenCookie(String token) {
        return ResponseCookie.from("refresh_token", token)
                .httpOnly(true)
                .secure(secure)
                .sameSite("Strict")       // Stricter for Refresh
                .domain(cookieDomain)
                .path("/api/auth/refresh") // Only sent to refresh endpoint
                .maxAge(Duration.ofDays(7))
                .build();
    }

    /**
     * Delete cookie (maxAge=0)
     */
    public ResponseCookie deleteAccessTokenCookie() {
        return ResponseCookie.from("access_token", "")
                .httpOnly(true)
                .secure(secure)
                .sameSite("Lax")
                .domain(cookieDomain)
                .path("/")
                .maxAge(0)    // Delete immediately
                .build();
    }

    public ResponseCookie deleteRefreshTokenCookie() {
        return ResponseCookie.from("refresh_token", "")
                .httpOnly(true)
                .secure(secure)
                .sameSite("Strict")
                .domain(cookieDomain)
                .path("/api/auth/refresh")
                .maxAge(0)
                .build();
    }
}

Setting Cookies in Login Response

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

    private final AuthenticationManager authManager;
    private final JwtTokenProvider jwtTokenProvider;
    private final CookieUtil cookieUtil;

    // Constructor omitted

    @PostMapping("/login")
    public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request) {
        Authentication auth = authManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                request.getUsername(), request.getPassword()));

        UserDetails userDetails = (UserDetails) auth.getPrincipal();
        String tenantId = ((CustomUserDetails) userDetails).getTenantId();

        String accessToken = jwtTokenProvider.generateAccessToken(userDetails, tenantId);
        String refreshToken = jwtTokenProvider.generateRefreshToken(userDetails);

        ResponseCookie accessCookie = cookieUtil.createAccessTokenCookie(accessToken);
        ResponseCookie refreshCookie = cookieUtil.createRefreshTokenCookie(refreshToken);

        return ResponseEntity.ok()
                .header(HttpHeaders.SET_COOKIE, accessCookie.toString())
                .header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
                .body(Map.of(
                    "message", "Login successful",
                    "username", userDetails.getUsername(),
                    "roles", userDetails.getAuthorities()
                ));
    }
}

Claim Parsing and User Information Access

Custom UserDetails

@Getter
public class CustomUserDetails implements UserDetails {

    private final Long id;
    private final String username;
    private final String password;
    private final String tenantId;
    private final Collection<? extends GrantedAuthority> authorities;

    public CustomUserDetails(User user) {
        this.id = user.getId();
        this.username = user.getUsername();
        this.password = user.getPassword();
        this.tenantId = user.getTenantId();
        this.authorities = user.getRoles().stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
                .collect(Collectors.toList());
    }
}

Accessing User Information in Controllers

@RestController
@RequestMapping("/api/users")
public class UserController {

    // Method 1: Direct SecurityContextHolder usage
    @GetMapping("/me")
    public ResponseEntity<?> getCurrentUser() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        CustomUserDetails user = (CustomUserDetails) auth.getPrincipal();

        return ResponseEntity.ok(Map.of(
            "id", user.getId(),
            "username", user.getUsername(),
            "tenantId", user.getTenantId(),
            "roles", user.getAuthorities()
        ));
    }

    // Method 2: @AuthenticationPrincipal annotation (recommended)
    @GetMapping("/profile")
    public ResponseEntity<?> getProfile(
            @AuthenticationPrincipal CustomUserDetails user) {
        return ResponseEntity.ok(Map.of(
            "id", user.getId(),
            "username", user.getUsername(),
            "tenantId", user.getTenantId()
        ));
    }

    // Method 3: Extract directly from JWT Claims
    @GetMapping("/claims")
    public ResponseEntity<?> getClaimsInfo(HttpServletRequest request) {
        String token = extractTokenFromCookie(request);
        Claims claims = jwtTokenProvider.extractAllClaims(token);

        return ResponseEntity.ok(Map.of(
            "sub", claims.getSubject(),
            "roles", claims.get("roles"),
            "tenant_id", claims.get("tenant_id"),
            "exp", claims.getExpiration(),
            "iat", claims.getIssuedAt()
        ));
    }

    // Method 4: Method-level authorization with @PreAuthorize
    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin/all")
    public ResponseEntity<?> getAllUsers() {
        // ADMIN only
        return ResponseEntity.ok(userService.findAll());
    }

    @PreAuthorize("#userId == authentication.principal.id")
    @PutMapping("/{userId}")
    public ResponseEntity<?> updateUser(@PathVariable Long userId,
                                         @RequestBody UpdateRequest req) {
        // Can only modify own profile
        return ResponseEntity.ok(userService.update(userId, req));
    }
}

Browser Storage Accessibility Table

StorageJS AccessAuto Server SendXSS VulnerableCSRF Vulnerable
localStorageYesNoYes (risky)No
sessionStorageYesNoYes (risky)No
Regular CookieYesYes (same domain)YesYes (risky)
HttpOnly CookieNo (safe)Yes (same domain)No (safe)Yes (mitigated by SameSite)
HttpOnly + SameSite=StrictNoSame site onlyNoNo (safe)
HttpOnly + SameSite=LaxNoCross-site GET allowedNoNearly safe

Recommended combination: HttpOnly + Secure + SameSite=Lax (Access Token), HttpOnly + Secure + SameSite=Strict (Refresh Token)


CORS + Credential Transmission Setup

When frontend and backend domains differ, CORS + credentials configuration is required to transmit cookies.

Spring Boot CORS Configuration

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();

    // Cannot use * in allowedOrigins (when credentials: true)
    config.setAllowedOrigins(List.of(
        "https://app.example.com",
        "https://admin.example.com"
    ));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
    config.setAllowedHeaders(List.of(
        "Authorization", "Content-Type", "X-Requested-With", "Accept"));
    config.setExposedHeaders(List.of("Set-Cookie"));
    config.setAllowCredentials(true);  // Must be true
    config.setMaxAge(3600L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);
    return source;
}

Frontend (fetch / axios)

// fetch
const res = await fetch('https://api.example.com/api/users/me', {
  method: 'GET',
  credentials: 'include', // Required for cookie transmission
})

// axios global configuration
axios.defaults.withCredentials = true

Note: When using allowCredentials(true), you cannot use the "*" wildcard in allowedOrigins. You must explicitly list domains.


Logout and Token Invalidation

Since JWT does not store state on the server, "invalidation" is difficult. A Redis-based blacklist is the most practical approach.

Redis Blacklist Service

@Service
public class TokenBlacklistService {

    private final RedisTemplate<String, String> redisTemplate;

    public TokenBlacklistService(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * Add token to blacklist (set TTL to remaining expiration time)
     */
    public void blacklist(String token, long remainingMillis) {
        redisTemplate.opsForValue().set(
            "blacklist:" + token,
            "revoked",
            Duration.ofMillis(remainingMillis)
        );
    }

    /**
     * Check if token is blacklisted
     */
    public boolean isBlacklisted(String token) {
        return Boolean.TRUE.equals(
            redisTemplate.hasKey("blacklist:" + token));
    }
}

Logout Endpoint

@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletRequest request) {
    String token = resolveToken(request);

    if (token != null) {
        try {
            Claims claims = jwtTokenProvider.extractAllClaims(token);
            long remainingMillis =
                claims.getExpiration().getTime() - System.currentTimeMillis();

            if (remainingMillis > 0) {
                tokenBlacklistService.blacklist(token, remainingMillis);
            }
        } catch (JwtException ignored) {
            // Already expired tokens don't need to be blacklisted
        }
    }

    // Delete cookies
    ResponseCookie deleteAccess = cookieUtil.deleteAccessTokenCookie();
    ResponseCookie deleteRefresh = cookieUtil.deleteRefreshTokenCookie();

    return ResponseEntity.ok()
            .header(HttpHeaders.SET_COOKIE, deleteAccess.toString())
            .header(HttpHeaders.SET_COOKIE, deleteRefresh.toString())
            .body(Map.of("message", "Logout successful"));
}

Token Refresh Strategy

Set Access Token short (15 minutes) and Refresh Token long (7 days), and issue new Access Tokens using the Refresh Token. Applying Refresh Token Rotation ensures that even stolen Refresh Tokens can only be used once.

Refresh Endpoint

@PostMapping("/refresh")
public ResponseEntity<?> refresh(HttpServletRequest request) {
    // 1. Extract Refresh Token from cookie
    String refreshToken = Arrays.stream(
            Optional.ofNullable(request.getCookies()).orElse(new Cookie[0]))
        .filter(c -> "refresh_token".equals(c.getName()))
        .findFirst()
        .map(Cookie::getValue)
        .orElseThrow(() -> new AuthException("Refresh Token not found"));

    // 2. Validate Refresh Token
    Claims claims;
    try {
        claims = jwtTokenProvider.extractAllClaims(refreshToken);
    } catch (JwtException e) {
        throw new AuthException("Invalid Refresh Token");
    }

    if (!"refresh".equals(claims.get("token_type"))) {
        throw new AuthException("Wrong token type");
    }

    // 3. Check blacklist
    if (tokenBlacklistService.isBlacklisted(refreshToken)) {
        throw new AuthException("Already used Refresh Token (Rotation violation detected)");
    }

    // 4. Load user information
    String username = claims.getSubject();
    CustomUserDetails userDetails =
        (CustomUserDetails) userDetailsService.loadUserByUsername(username);

    // 5. Blacklist existing Refresh Token (Rotation)
    long remaining = claims.getExpiration().getTime() - System.currentTimeMillis();
    tokenBlacklistService.blacklist(refreshToken, remaining);

    // 6. Issue new token pair
    String newAccessToken = jwtTokenProvider.generateAccessToken(
        userDetails, userDetails.getTenantId());
    String newRefreshToken = jwtTokenProvider.generateRefreshToken(userDetails);

    // 7. Set new cookies
    return ResponseEntity.ok()
        .header(HttpHeaders.SET_COOKIE,
            cookieUtil.createAccessTokenCookie(newAccessToken).toString())
        .header(HttpHeaders.SET_COOKIE,
            cookieUtil.createRefreshTokenCookie(newRefreshToken).toString())
        .body(Map.of("message", "Token refresh successful"));
}

Rotation violation detection: If a Refresh Token already in the blacklist is used, it is considered an attack attempt, and defensive logic can be added to invalidate all tokens for that user.


Security Trade-offs

ThreatlocalStorage JWTHttpOnly Cookie JWTSession
XSSImmediately stealableJS access blocked (safe)Session ID inaccessible
CSRFNot applicableDefend with SameSite + CORSCSRF token required
Token theftEasily stolen via XSSNetwork-level onlyRisk if session ID leaked
Replay attackMitigate with short TTLShort TTL + blacklistServer invalidates session
ScalabilityEasy horizontal scalingEasy horizontal scalingRedis Session needed
Server loadNoneBlacklist lookupSession storage

Conclusion: In most cases, HttpOnly Cookie + SameSite=Lax + Secure + short expiration + Refresh Token Rotation is the most practical choice.


Checklist

Verify the following items when implementing Spring Boot JWT authentication:

  • Is the JWT Secret Key sufficiently long (256 bits or more)?
  • Is the Secret Key loaded from environment variables/Vault (not hardcoded)?
  • Is the Access Token expiration time short enough (15 minutes or less recommended)?
  • Is Refresh Token Rotation applied?
  • Are HttpOnly + Secure + SameSite cookie settings configured?
  • Are CORS allowCredentials(true) and explicit Origins set?
  • Is STATELESS session policy set in SecurityFilterChain?
  • Are exceptions in the JWT filter handled safely (preventing 500 errors)?
  • Are cookie deletion + token blacklist implemented on logout?
  • Does the Token blacklist TTL match the token expiration time?
  • Are @PreAuthorize or URL-based authorization rules configured?
  • Is CSRF configuration appropriate for the API characteristics?
  • Are ExceptionHandling (authenticationEntryPoint, accessDeniedHandler) configured?
  • Does the Refresh endpoint only accept Refresh Tokens (token_type validation)?
  • Are sensitive Claims (passwords, etc.) excluded from the JWT?

Common Bugs and Misconceptions

1. "It's always okay to disable CSRF"

If JWT is sent via the Authorization header, CSRF attacks are impossible (browser doesn't auto-attach it). However, if JWT is stored in cookies, disabling CSRF without SameSite settings creates a vulnerability.

2. "Sensitive info in JWT is safe because it's encrypted"

JWT is Signed, not Encrypted. Anyone can see the Payload by simply Base64 decoding it. Never include passwords, social security numbers, or other sensitive information.

3. "SecurityContextHolder is always thread-safe"

The default strategy is ThreadLocal, so it is safe within the same thread. However, in code executing in different threads such as @Async, CompletableFuture, or WebFlux, SecurityContext is not propagated. You need to use SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL) or DelegatingSecurityContextExecutor.

4. "It's OncePerRequestFilter but why is it executing twice?"

The filter can execute again during servlet forwarding (RequestDispatcher.forward()) or error dispatch. Override the shouldNotFilter() method or check shouldNotFilterErrorDispatch().

5. "I put * in allowedOrigins but cookies aren't being sent"

allowCredentials(true) and allowedOrigins("*") cannot be used together. You must explicitly list domains or use allowedOriginPatterns("*") (not recommended for security).

6. "Is it okay to store Refresh Token in localStorage?"

The Refresh Token has a longer lifespan than the Access Token and is a sensitive token that can issue new Access Tokens. Since localStorage is vulnerable to XSS, it must be stored in HttpOnly cookies.

7. "I set JWT expiration to 1 year"

If the Access Token expiration is long, the damage from theft increases. Access Token is typically 15 minutes or less, and Refresh Token is 7-30 days.


References

  1. Spring Security Reference — Architecture — Filter chain architecture
  2. Spring Security Reference — Authentication — Authentication mechanisms
  3. Spring Security Reference — Authorization — Authorization mechanisms
  4. jjwt GitHub — Java JWT library
  5. RFC 7519 — JSON Web Token — JWT standard spec
  6. RFC 6749 — OAuth 2.0 — OAuth 2.0 framework
  7. OWASP — Session Management Cheat Sheet — Session management security
  8. OWASP — JWT Security Cheat Sheet — JWT security
  9. OWASP — Cross-Site Request Forgery Prevention — CSRF defense
  10. MDN — Set-Cookie — Cookie header spec
  11. MDN — SameSite cookies — SameSite attribute
  12. Spring Boot + Redis Session — Redis-based session sharing
  13. Baeldung — Spring Security JWT — JWT authentication tutorial