Skip to content
Published on

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

Authors
  • Name
    Twitter

📚 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 인증 튜토리얼