Skip to content

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

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

> **📚 SSO 쿠키/JWT 인증 시리즈** > [인덱스](/blog/architecture/2026-03-08-sso-cookie-jwt-auth-series-index) ← 현재: Spring Boot 편 → [Django 편](/blog/architecture/2026-03-08-sso-cookie-jwt-auth-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 취약 |

| ------------------------------ | ------------ | --------------------- | ------------ | ------------------- |

| **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일이 일반적입니다.

참고자료

1. [Spring Security Reference — Architecture](https://docs.spring.io/spring-security/reference/servlet/architecture.html) — 필터 체인 아키텍처

2. [Spring Security Reference — Authentication](https://docs.spring.io/spring-security/reference/servlet/authentication/index.html) — 인증 메커니즘

3. [Spring Security Reference — Authorization](https://docs.spring.io/spring-security/reference/servlet/authorization/index.html) — 인가 메커니즘

4. [jjwt GitHub](https://github.com/jwtk/jjwt) — Java JWT 라이브러리

5. [RFC 7519 — JSON Web Token](https://datatracker.ietf.org/doc/html/rfc7519) — JWT 표준 스펙

6. [RFC 6749 — OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749) — OAuth 2.0 프레임워크

7. [OWASP — Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) — 세션 관리 보안

8. [OWASP — JWT Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html) — JWT 보안

9. [OWASP — Cross-Site Request Forgery Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) — CSRF 방어

10. [MDN — Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) — 쿠키 헤더 스펙

11. [MDN — SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value) — SameSite 속성

12. [Spring Boot + Redis Session](https://docs.spring.io/spring-session/reference/guides/boot-redis.html) — Redis 기반 세션 공유

13. [Baeldung — Spring Security JWT](https://www.baeldung.com/spring-security-oauth-jwt) — JWT 인증 튜토리얼

현재 단락 (1/636)

Spring Security는 **서블릿 필터 체인(Servlet Filter Chain)** 위에 보안 로직을 쌓는 구조입니다. HTTP 요청이 컨트롤러에 도달하기 전에 여러 보...

작성 글자: 0원문 글자: 22,236작성 단락: 0/636