TT Lab

한국어 English 日本語

시작하기
블로그

블로그

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
  ↓
DispatcherServlet → Controller

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

  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 인증을 구현할 때 아래 항목을 확인하세요.


흔한 버그와 오해

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 인증 튜토리얼
로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다