필사 모드: Spring Boot Authentication Practical Guide — Session, JWT, SecurityContext, Cookie-Based Auth Complete Mastery
English> **SSO Cookie/JWT Authentication Series** · [Index](/blog/architecture/2026-03-08-sso-cookie-jwt-auth-series-index) · Current: Spring Boot Edition · [Django Edition](/blog/architecture/2026-03-08-sso-cookie-jwt-auth-django)
Overview — Spring Boot Authentication Architecture
Spring Security builds security logic on top of the **Servlet Filter Chain**. Before an HTTP request reaches the controller, it passes through multiple security filters where Authentication and Authorization take place.
HTTP Request
|
DelegatingFilterProxy
|
FilterChainProxy
|
SecurityFilterChain
|-- CorsFilter
|-- CsrfFilter
|-- UsernamePasswordAuthenticationFilter (Session-based)
|-- JwtAuthenticationFilter (Custom JWT filter)
|-- ExceptionTranslationFilter
+-- AuthorizationFilter
|
DispatcherServlet -> Controller
The core flow is as follows:
1. **SecurityFilterChain** — Determines which filters to apply to which requests
2. **AuthenticationManager** — Manager that receives authentication processing delegation
3. **AuthenticationProvider** — Performs actual authentication logic (DB lookup, password verification, etc.)
4. **SecurityContext** — Stores authenticated user information in thread-local
// Basic structure
SecurityContextHolder
+-- SecurityContext
+-- Authentication
|-- Principal (User info)
|-- Credentials (Password/Token)
+-- Authorities (Permission list)
Session-Based Authentication
The most traditional approach. The server creates an `HttpSession` and issues a `JSESSIONID` cookie to the client.
Login Handler Implementation
@RestController
@RequestMapping("/api/auth")
public class SessionAuthController {
private final AuthenticationManager authenticationManager;
public SessionAuthController(AuthenticationManager authenticationManager) {
this.authenticationManager = authenticationManager;
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
// 1. Authentication attempt
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
// 2. Store in SecurityContext
SecurityContextHolder.getContext().setAuthentication(authentication);
// 3. Create session (JSESSIONID cookie automatically issued)
HttpSession session = httpRequest.getSession(true);
session.setAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
SecurityContextHolder.getContext()
);
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
return ResponseEntity.ok(Map.of(
"username", userDetails.getUsername(),
"roles", userDetails.getAuthorities()
));
}
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate(); // Invalidate session
}
SecurityContextHolder.clearContext();
return ResponseEntity.ok(Map.of("message", "Logout successful"));
}
}
SecurityConfig (Session-Based)
@Configuration
@EnableWebSecurity
public class SessionSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.csrfTokenRepository(
CookieCsrfTokenRepository.withHttpOnlyFalse()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login", "/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1) // Concurrent session limit
.maxSessionsPreventsLogin(false) // Expire existing session
);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
Session-based authentication is simple to implement, but **session sharing issues arise when scaling out servers**. This can be resolved with Redis-based Spring Session, but it increases complexity.
JWT-Based Authentication
JWT Token Generation
Uses the `io.jsonwebtoken` (jjwt) library. Add the dependency to `build.gradle`.
// build.gradle
dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
}
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.access-token-expiration}")
private long accessTokenExpiration; // e.g. 900000 (15 min)
@Value("${jwt.refresh-token-expiration}")
private long refreshTokenExpiration; // e.g. 604800000 (7 days)
private SecretKey key;
@PostConstruct
public void init() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
/** Generate Access Token */
public String generateAccessToken(UserDetails userDetails, String tenantId) {
Map<String, Object> claims = new HashMap<>();
claims.put("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
claims.put("tenant_id", tenantId);
claims.put("token_type", "access");
return Jwts.builder()
.claims(claims)
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + accessTokenExpiration))
.signWith(key)
.compact();
}
/** Generate Refresh Token */
public String generateRefreshToken(UserDetails userDetails) {
return Jwts.builder()
.subject(userDetails.getUsername())
.claim("token_type", "refresh")
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + refreshTokenExpiration))
.signWith(key)
.compact();
}
/** Extract Claims from token */
public Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
/** Extract username from token */
public String extractUsername(String token) {
return extractAllClaims(token).getSubject();
}
/** Validate token */
public boolean isTokenValid(String token, UserDetails userDetails) {
try {
Claims claims = extractAllClaims(token);
String username = claims.getSubject();
Date expiration = claims.getExpiration();
return username.equals(userDetails.getUsername())
&& expiration.after(new Date());
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
}
JWT Filter Implementation
Extends `OncePerRequestFilter` to verify JWT once per request. Supports both **Authorization header** and **HttpOnly cookie** methods.
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final UserDetailsService userDetailsService;
private final TokenBlacklistService tokenBlacklistService;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider,
UserDetailsService userDetailsService,
TokenBlacklistService tokenBlacklistService) {
this.jwtTokenProvider = jwtTokenProvider;
this.userDetailsService = userDetailsService;
this.tokenBlacklistService = tokenBlacklistService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && !tokenBlacklistService.isBlacklisted(token)) {
try {
String username = jwtTokenProvider.extractUsername(token);
if (username != null &&
SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails =
userDetailsService.loadUserByUsername(username);
if (jwtTokenProvider.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(
new WebAuthenticationDetailsSource()
.buildDetails(request)
);
SecurityContextHolder.getContext()
.setAuthentication(authToken);
}
}
} catch (JwtException e) {
// Invalid token — proceed to next filter without authentication
logger.warn("JWT validation failed: " + e.getMessage());
}
}
filterChain.doFilter(request, response);
}
/**
* Token extraction: Authorization header first, then cookie
*/
private String resolveToken(HttpServletRequest request) {
// 1) Authorization: Bearer <token>
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
// 2) HttpOnly Cookie: access_token=<token>
if (request.getCookies() != null) {
return Arrays.stream(request.getCookies())
.filter(c -> "access_token".equals(c.getName()))
.findFirst()
.map(Cookie::getValue)
.orElse(null);
}
return null;
}
}
SecurityFilterChain Configuration (JWT)
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // Enable @PreAuthorize, @Secured
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final AuthenticationProvider authenticationProvider;
public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter,
AuthenticationProvider authenticationProvider) {
this.jwtAuthFilter = jwtAuthFilter;
this.authenticationProvider = authenticationProvider;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// CSRF: Can be disabled when using JWT + SameSite cookies
.csrf(AbstractHttpConfigurer::disable)
// CORS configuration
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// Do not use sessions (STATELESS)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// URL-based authorization rules
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/auth/login",
"/api/auth/register",
"/api/auth/refresh",
"/api/public/**",
"/actuator/health"
).permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/tenant/**").hasAnyRole("ADMIN", "TENANT_ADMIN")
.anyRequest().authenticated()
)
// Register AuthenticationProvider
.authenticationProvider(authenticationProvider)
// Place JWT filter before UsernamePasswordAuthenticationFilter
.addFilterBefore(jwtAuthFilter,
UsernamePasswordAuthenticationFilter.class)
// Authentication failure handling
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"error\":\"Authentication required\",\"status\":401}");
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"error\":\"Access denied\",\"status\":403}");
})
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"https://app.example.com",
"https://admin.example.com"
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true); // Allow cookie transmission
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
}
Cookie Configuration in Practice
When JWTs are sent via cookies, the frontend does not need to manage tokens. The combination of **HttpOnly + Secure + SameSite** is the key.
Cookie Creation Utility
@Component
public class CookieUtil {
@Value("${app.cookie.domain}")
private String cookieDomain; // e.g. ".example.com"
@Value("${app.cookie.secure}")
private boolean secure; // Production: true, Local: false
/**
* Create Access Token cookie
*/
public ResponseCookie createAccessTokenCookie(String token) {
return ResponseCookie.from("access_token", token)
.httpOnly(true) // Block JS access (XSS defense)
.secure(secure) // HTTPS only
.sameSite("Lax") // Basic CSRF defense
.domain(cookieDomain) // Subdomain sharing
.path("/") // All paths
.maxAge(Duration.ofMinutes(15))
.build();
}
/**
* Create Refresh Token cookie
*/
public ResponseCookie createRefreshTokenCookie(String token) {
return ResponseCookie.from("refresh_token", token)
.httpOnly(true)
.secure(secure)
.sameSite("Strict") // Stricter for Refresh
.domain(cookieDomain)
.path("/api/auth/refresh") // Only sent to refresh endpoint
.maxAge(Duration.ofDays(7))
.build();
}
/**
* Delete cookie (maxAge=0)
*/
public ResponseCookie deleteAccessTokenCookie() {
return ResponseCookie.from("access_token", "")
.httpOnly(true)
.secure(secure)
.sameSite("Lax")
.domain(cookieDomain)
.path("/")
.maxAge(0) // Delete immediately
.build();
}
public ResponseCookie deleteRefreshTokenCookie() {
return ResponseCookie.from("refresh_token", "")
.httpOnly(true)
.secure(secure)
.sameSite("Strict")
.domain(cookieDomain)
.path("/api/auth/refresh")
.maxAge(0)
.build();
}
}
Setting Cookies in Login Response
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authManager;
private final JwtTokenProvider jwtTokenProvider;
private final CookieUtil cookieUtil;
// Constructor omitted
@PostMapping("/login")
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request) {
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(), request.getPassword()));
UserDetails userDetails = (UserDetails) auth.getPrincipal();
String tenantId = ((CustomUserDetails) userDetails).getTenantId();
String accessToken = jwtTokenProvider.generateAccessToken(userDetails, tenantId);
String refreshToken = jwtTokenProvider.generateRefreshToken(userDetails);
ResponseCookie accessCookie = cookieUtil.createAccessTokenCookie(accessToken);
ResponseCookie refreshCookie = cookieUtil.createRefreshTokenCookie(refreshToken);
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, accessCookie.toString())
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
.body(Map.of(
"message", "Login successful",
"username", userDetails.getUsername(),
"roles", userDetails.getAuthorities()
));
}
}
Claim Parsing and User Information Access
Custom UserDetails
@Getter
public class CustomUserDetails implements UserDetails {
private final Long id;
private final String username;
private final String password;
private final String tenantId;
private final Collection<? extends GrantedAuthority> authorities;
public CustomUserDetails(User user) {
this.id = user.getId();
this.username = user.getUsername();
this.password = user.getPassword();
this.tenantId = user.getTenantId();
this.authorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
.collect(Collectors.toList());
}
}
Accessing User Information in Controllers
@RestController
@RequestMapping("/api/users")
public class UserController {
// Method 1: Direct SecurityContextHolder usage
@GetMapping("/me")
public ResponseEntity<?> getCurrentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
CustomUserDetails user = (CustomUserDetails) auth.getPrincipal();
return ResponseEntity.ok(Map.of(
"id", user.getId(),
"username", user.getUsername(),
"tenantId", user.getTenantId(),
"roles", user.getAuthorities()
));
}
// Method 2: @AuthenticationPrincipal annotation (recommended)
@GetMapping("/profile")
public ResponseEntity<?> getProfile(
@AuthenticationPrincipal CustomUserDetails user) {
return ResponseEntity.ok(Map.of(
"id", user.getId(),
"username", user.getUsername(),
"tenantId", user.getTenantId()
));
}
// Method 3: Extract directly from JWT Claims
@GetMapping("/claims")
public ResponseEntity<?> getClaimsInfo(HttpServletRequest request) {
String token = extractTokenFromCookie(request);
Claims claims = jwtTokenProvider.extractAllClaims(token);
return ResponseEntity.ok(Map.of(
"sub", claims.getSubject(),
"roles", claims.get("roles"),
"tenant_id", claims.get("tenant_id"),
"exp", claims.getExpiration(),
"iat", claims.getIssuedAt()
));
}
// Method 4: Method-level authorization with @PreAuthorize
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin/all")
public ResponseEntity<?> getAllUsers() {
// ADMIN only
return ResponseEntity.ok(userService.findAll());
}
@PreAuthorize("#userId == authentication.principal.id")
@PutMapping("/{userId}")
public ResponseEntity<?> updateUser(@PathVariable Long userId,
@RequestBody UpdateRequest req) {
// Can only modify own profile
return ResponseEntity.ok(userService.update(userId, req));
}
}
Browser Storage Accessibility Table
| Storage | JS Access | Auto Server Send | XSS Vulnerable | CSRF Vulnerable |
| ------------------------------ | ------------- | ---------------------- | --------------- | --------------------------- |
| **localStorage** | Yes | No | **Yes (risky)** | No |
| **sessionStorage** | Yes | No | **Yes (risky)** | No |
| **Regular Cookie** | Yes | Yes (same domain) | **Yes** | **Yes (risky)** |
| **HttpOnly Cookie** | **No (safe)** | Yes (same domain) | **No (safe)** | Yes (mitigated by SameSite) |
| **HttpOnly + SameSite=Strict** | **No** | Same site only | **No** | **No (safe)** |
| **HttpOnly + SameSite=Lax** | **No** | Cross-site GET allowed | **No** | Nearly safe |
**Recommended combination**: `HttpOnly + Secure + SameSite=Lax` (Access Token), `HttpOnly + Secure + SameSite=Strict` (Refresh Token)
CORS + Credential Transmission Setup
When frontend and backend domains differ, **CORS + credentials** configuration is required to transmit cookies.
Spring Boot CORS Configuration
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// Cannot use * in allowedOrigins (when credentials: true)
config.setAllowedOrigins(List.of(
"https://app.example.com",
"https://admin.example.com"
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of(
"Authorization", "Content-Type", "X-Requested-With", "Accept"));
config.setExposedHeaders(List.of("Set-Cookie"));
config.setAllowCredentials(true); // Must be true
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
Frontend (fetch / axios)
// fetch
const res = await fetch('https://api.example.com/api/users/me', {
method: 'GET',
credentials: 'include', // Required for cookie transmission
})
// axios global configuration
axios.defaults.withCredentials = true
> **Note**: When using `allowCredentials(true)`, you cannot use the `"*"` wildcard in `allowedOrigins`. You must explicitly list domains.
Logout and Token Invalidation
Since JWT does not store state on the server, "invalidation" is difficult. A **Redis-based blacklist** is the most practical approach.
Redis Blacklist Service
@Service
public class TokenBlacklistService {
private final RedisTemplate<String, String> redisTemplate;
public TokenBlacklistService(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* Add token to blacklist (set TTL to remaining expiration time)
*/
public void blacklist(String token, long remainingMillis) {
redisTemplate.opsForValue().set(
"blacklist:" + token,
"revoked",
Duration.ofMillis(remainingMillis)
);
}
/**
* Check if token is blacklisted
*/
public boolean isBlacklisted(String token) {
return Boolean.TRUE.equals(
redisTemplate.hasKey("blacklist:" + token));
}
}
Logout Endpoint
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletRequest request) {
String token = resolveToken(request);
if (token != null) {
try {
Claims claims = jwtTokenProvider.extractAllClaims(token);
long remainingMillis =
claims.getExpiration().getTime() - System.currentTimeMillis();
if (remainingMillis > 0) {
tokenBlacklistService.blacklist(token, remainingMillis);
}
} catch (JwtException ignored) {
// Already expired tokens don't need to be blacklisted
}
}
// Delete cookies
ResponseCookie deleteAccess = cookieUtil.deleteAccessTokenCookie();
ResponseCookie deleteRefresh = cookieUtil.deleteRefreshTokenCookie();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, deleteAccess.toString())
.header(HttpHeaders.SET_COOKIE, deleteRefresh.toString())
.body(Map.of("message", "Logout successful"));
}
Token Refresh Strategy
Set Access Token short (15 minutes) and Refresh Token long (7 days), and issue new Access Tokens using the Refresh Token. Applying **Refresh Token Rotation** ensures that even stolen Refresh Tokens can only be used once.
Refresh Endpoint
@PostMapping("/refresh")
public ResponseEntity<?> refresh(HttpServletRequest request) {
// 1. Extract Refresh Token from cookie
String refreshToken = Arrays.stream(
Optional.ofNullable(request.getCookies()).orElse(new Cookie[0]))
.filter(c -> "refresh_token".equals(c.getName()))
.findFirst()
.map(Cookie::getValue)
.orElseThrow(() -> new AuthException("Refresh Token not found"));
// 2. Validate Refresh Token
Claims claims;
try {
claims = jwtTokenProvider.extractAllClaims(refreshToken);
} catch (JwtException e) {
throw new AuthException("Invalid Refresh Token");
}
if (!"refresh".equals(claims.get("token_type"))) {
throw new AuthException("Wrong token type");
}
// 3. Check blacklist
if (tokenBlacklistService.isBlacklisted(refreshToken)) {
throw new AuthException("Already used Refresh Token (Rotation violation detected)");
}
// 4. Load user information
String username = claims.getSubject();
CustomUserDetails userDetails =
(CustomUserDetails) userDetailsService.loadUserByUsername(username);
// 5. Blacklist existing Refresh Token (Rotation)
long remaining = claims.getExpiration().getTime() - System.currentTimeMillis();
tokenBlacklistService.blacklist(refreshToken, remaining);
// 6. Issue new token pair
String newAccessToken = jwtTokenProvider.generateAccessToken(
userDetails, userDetails.getTenantId());
String newRefreshToken = jwtTokenProvider.generateRefreshToken(userDetails);
// 7. Set new cookies
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE,
cookieUtil.createAccessTokenCookie(newAccessToken).toString())
.header(HttpHeaders.SET_COOKIE,
cookieUtil.createRefreshTokenCookie(newRefreshToken).toString())
.body(Map.of("message", "Token refresh successful"));
}
**Rotation violation detection**: If a Refresh Token already in the blacklist is used, it is considered an attack attempt, and defensive logic can be added to **invalidate all tokens** for that user.
Security Trade-offs
| Threat | localStorage JWT | HttpOnly Cookie JWT | Session |
| ----------------- | ----------------------- | --------------------------- | -------------------------- |
| **XSS** | Immediately stealable | JS access blocked (safe) | Session ID inaccessible |
| **CSRF** | Not applicable | Defend with SameSite + CORS | CSRF token required |
| **Token theft** | Easily stolen via XSS | Network-level only | Risk if session ID leaked |
| **Replay attack** | Mitigate with short TTL | Short TTL + blacklist | Server invalidates session |
| **Scalability** | Easy horizontal scaling | Easy horizontal scaling | Redis Session needed |
| **Server load** | None | Blacklist lookup | Session storage |
**Conclusion**: In most cases, **HttpOnly Cookie + SameSite=Lax + Secure + short expiration + Refresh Token Rotation** is the most practical choice.
Checklist
Verify the following items when implementing Spring Boot JWT authentication:
- [ ] Is the JWT Secret Key sufficiently long (256 bits or more)?
- [ ] Is the Secret Key loaded from environment variables/Vault (not hardcoded)?
- [ ] Is the Access Token expiration time short enough (15 minutes or less recommended)?
- [ ] Is Refresh Token Rotation applied?
- [ ] Are HttpOnly + Secure + SameSite cookie settings configured?
- [ ] Are CORS allowCredentials(true) and explicit Origins set?
- [ ] Is STATELESS session policy set in SecurityFilterChain?
- [ ] Are exceptions in the JWT filter handled safely (preventing 500 errors)?
- [ ] Are cookie deletion + token blacklist implemented on logout?
- [ ] Does the Token blacklist TTL match the token expiration time?
- [ ] Are @PreAuthorize or URL-based authorization rules configured?
- [ ] Is CSRF configuration appropriate for the API characteristics?
- [ ] Are ExceptionHandling (authenticationEntryPoint, accessDeniedHandler) configured?
- [ ] Does the Refresh endpoint only accept Refresh Tokens (token_type validation)?
- [ ] Are sensitive Claims (passwords, etc.) excluded from the JWT?
Common Bugs and Misconceptions
1. "It's always okay to disable CSRF"
If JWT is sent via the **Authorization header**, CSRF attacks are impossible (browser doesn't auto-attach it). However, if **JWT is stored in cookies**, disabling CSRF without SameSite settings creates a vulnerability.
2. "Sensitive info in JWT is safe because it's encrypted"
JWT is **Signed**, not **Encrypted**. Anyone can see the Payload by simply Base64 decoding it. Never include passwords, social security numbers, or other sensitive information.
3. "SecurityContextHolder is always thread-safe"
The default strategy is `ThreadLocal`, so it is safe within the same thread. However, in code executing in **different threads** such as `@Async`, `CompletableFuture`, or WebFlux, SecurityContext is not propagated. You need to use `SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL)` or `DelegatingSecurityContextExecutor`.
4. "It's OncePerRequestFilter but why is it executing twice?"
The filter can execute again during servlet forwarding (`RequestDispatcher.forward()`) or error dispatch. Override the `shouldNotFilter()` method or check `shouldNotFilterErrorDispatch()`.
5. "I put \* in allowedOrigins but cookies aren't being sent"
`allowCredentials(true)` and `allowedOrigins("*")` cannot be used together. You must explicitly list domains or use `allowedOriginPatterns("*")` (not recommended for security).
6. "Is it okay to store Refresh Token in localStorage?"
The Refresh Token has a longer lifespan than the Access Token and is a sensitive token that can issue new Access Tokens. Since localStorage is vulnerable to XSS, it **must be stored in HttpOnly cookies**.
7. "I set JWT expiration to 1 year"
If the Access Token expiration is long, the damage from theft increases. Access Token is typically 15 minutes or less, and Refresh Token is 7-30 days.
References
1. [Spring Security Reference — Architecture](https://docs.spring.io/spring-security/reference/servlet/architecture.html) — Filter chain architecture
2. [Spring Security Reference — Authentication](https://docs.spring.io/spring-security/reference/servlet/authentication/index.html) — Authentication mechanisms
3. [Spring Security Reference — Authorization](https://docs.spring.io/spring-security/reference/servlet/authorization/index.html) — Authorization mechanisms
4. [jjwt GitHub](https://github.com/jwtk/jjwt) — Java JWT library
5. [RFC 7519 — JSON Web Token](https://datatracker.ietf.org/doc/html/rfc7519) — JWT standard spec
6. [RFC 6749 — OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749) — OAuth 2.0 framework
7. [OWASP — Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) — Session management security
8. [OWASP — JWT Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html) — JWT security
9. [OWASP — Cross-Site Request Forgery Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) — CSRF defense
10. [MDN — Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) — Cookie header spec
11. [MDN — SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value) — SameSite attribute
12. [Spring Boot + Redis Session](https://docs.spring.io/spring-session/reference/guides/boot-redis.html) — Redis-based session sharing
13. [Baeldung — Spring Security JWT](https://www.baeldung.com/spring-security-oauth-jwt) — JWT authentication tutorial
현재 단락 (1/636)
Spring Security builds security logic on top of the **Servlet Filter Chain**. Before an HTTP request...