Skip to content
Published on

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

Authors

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:

  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;
    }
}

When JWTs are sent via cookies, the frontend does not need to manage tokens. The combination of HttpOnly + Secure + SameSite is the key.

@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

StorageJS AccessAuto Server SendXSS VulnerableCSRF Vulnerable
localStorageYesNoYes (risky)No
sessionStorageYesNoYes (risky)No
Regular CookieYesYes (same domain)YesYes (risky)
HttpOnly CookieNo (safe)Yes (same domain)No (safe)Yes (mitigated by SameSite)
HttpOnly + SameSite=StrictNoSame site onlyNoNo (safe)
HttpOnly + SameSite=LaxNoCross-site GET allowedNoNearly 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

ThreatlocalStorage JWTHttpOnly Cookie JWTSession
XSSImmediately stealableJS access blocked (safe)Session ID inaccessible
CSRFNot applicableDefend with SameSite + CORSCSRF token required
Token theftEasily stolen via XSSNetwork-level onlyRisk if session ID leaked
Replay attackMitigate with short TTLShort TTL + blacklistServer invalidates session
ScalabilityEasy horizontal scalingEasy horizontal scalingRedis Session needed
Server loadNoneBlacklist lookupSession 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 — Filter chain architecture
  2. Spring Security Reference — Authentication — Authentication mechanisms
  3. Spring Security Reference — Authorization — Authorization mechanisms
  4. jjwt GitHub — Java JWT library
  5. RFC 7519 — JSON Web Token — JWT standard spec
  6. RFC 6749 — OAuth 2.0 — OAuth 2.0 framework
  7. OWASP — Session Management Cheat Sheet — Session management security
  8. OWASP — JWT Security Cheat Sheet — JWT security
  9. OWASP — Cross-Site Request Forgery Prevention — CSRF defense
  10. MDN — Set-Cookie — Cookie header spec
  11. MDN — SameSite cookies — SameSite attribute
  12. Spring Boot + Redis Session — Redis-based session sharing
  13. Baeldung — Spring Security JWT — JWT authentication tutorial