- Published on
Spring Boot 인증 실전 가이드 — 세션, JWT, SecurityContext, 쿠키 기반 인증 완전 정복
- Authors
- Name
- 개요 — Spring Boot 인증 아키텍처
- 세션 기반 인증
- JWT 기반 인증
- 쿠키 설정 실전
- Claim 파싱 및 사용자 정보 접근
- 브라우저 저장소별 접근 가능성 표
- CORS + Credential 전송 설정
- 로그아웃 및 토큰 무효화
- 토큰 리프레시 전략
- 보안 트레이드오프
- 체크리스트
- 흔한 버그와 오해
- 참고자료
개요 — Spring Boot 인증 아키텍처
Spring Security는 서블릿 필터 체인(Servlet Filter Chain) 위에 보안 로직을 쌓는 구조입니다. HTTP 요청이 컨트롤러에 도달하기 전에 여러 보안 필터를 거치며, 인증(Authentication)과 인가(Authorization)가 이루어집니다.
HTTP 요청
↓
DelegatingFilterProxy
↓
FilterChainProxy
↓
SecurityFilterChain
├── CorsFilter
├── CsrfFilter
├── UsernamePasswordAuthenticationFilter (세션 기반)
├── JwtAuthenticationFilter (커스텀 JWT 필터)
├── ExceptionTranslationFilter
└── AuthorizationFilter
↓
DispatcherServlet → Controller
핵심 흐름은 다음과 같습니다.
- SecurityFilterChain — 어떤 요청에 어떤 필터를 적용할지 결정
- AuthenticationManager — 인증 처리를 위임받는 관리자
- AuthenticationProvider — 실제 인증 로직 (DB 조회, 비밀번호 검증 등)
- 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 취약 |
|---|---|---|---|---|
| localStorage | O | X | O (위험) | X |
| sessionStorage | O | X | O (위험) | X |
| 일반 Cookie | O | O (같은 도메인) | O | O (위험) |
| HttpOnly Cookie | X (안전) | O (같은 도메인) | X (안전) | O (SameSite로 완화) |
| HttpOnly + SameSite=Strict | X | 같은 사이트만 | X | X (안전) |
| HttpOnly + SameSite=Lax | X | GET 크로스사이트 허용 | 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 JWT | HttpOnly 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일이 일반적입니다.
참고자료
- Spring Security Reference — Architecture — 필터 체인 아키텍처
- Spring Security Reference — Authentication — 인증 메커니즘
- Spring Security Reference — Authorization — 인가 메커니즘
- jjwt GitHub — Java JWT 라이브러리
- RFC 7519 — JSON Web Token — JWT 표준 스펙
- RFC 6749 — OAuth 2.0 — OAuth 2.0 프레임워크
- OWASP — Session Management Cheat Sheet — 세션 관리 보안
- OWASP — JWT Security Cheat Sheet — JWT 보안
- OWASP — Cross-Site Request Forgery Prevention — CSRF 방어
- MDN — Set-Cookie — 쿠키 헤더 스펙
- MDN — SameSite cookies — SameSite 속성
- Spring Boot + Redis Session — Redis 기반 세션 공유
- Baeldung — Spring Security JWT — JWT 인증 튜토리얼