- Published on
Spring Boot Authentication Practical Guide — Session, JWT, SecurityContext, Cookie-Based Auth Complete Mastery
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Overview — Spring Boot Authentication Architecture
- Session-Based Authentication
- JWT-Based Authentication
- Cookie Configuration in Practice
- Claim Parsing and User Information Access
- Browser Storage Accessibility Table
- CORS + Credential Transmission Setup
- Logout and Token Invalidation
- Token Refresh Strategy
- Security Trade-offs
- Checklist
- Common Bugs and Misconceptions
- 1. "It's always okay to disable CSRF"
- 2. "Sensitive info in JWT is safe because it's encrypted"
- 3. "SecurityContextHolder is always thread-safe"
- 4. "It's OncePerRequestFilter but why is it executing twice?"
- 5. "I put * in allowedOrigins but cookies aren't being sent"
- 6. "Is it okay to store Refresh Token in localStorage?"
- 7. "I set JWT expiration to 1 year"
- References
SSO Cookie/JWT Authentication Series · Index · Current: Spring Boot Edition · Django Edition
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:
- SecurityFilterChain — Determines which filters to apply to which requests
- AuthenticationManager — Manager that receives authentication processing delegation
- AuthenticationProvider — Performs actual authentication logic (DB lookup, password verification, etc.)
- 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 inallowedOrigins. 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
- Spring Security Reference — Architecture — Filter chain architecture
- Spring Security Reference — Authentication — Authentication mechanisms
- Spring Security Reference — Authorization — Authorization mechanisms
- jjwt GitHub — Java JWT library
- RFC 7519 — JSON Web Token — JWT standard spec
- RFC 6749 — OAuth 2.0 — OAuth 2.0 framework
- OWASP — Session Management Cheat Sheet — Session management security
- OWASP — JWT Security Cheat Sheet — JWT security
- OWASP — Cross-Site Request Forgery Prevention — CSRF defense
- MDN — Set-Cookie — Cookie header spec
- MDN — SameSite cookies — SameSite attribute
- Spring Boot + Redis Session — Redis-based session sharing
- Baeldung — Spring Security JWT — JWT authentication tutorial