JWT Filter and Stateless Sessions

The Filter

On every request, a filter reads the Authorization: Bearer ... header, verifies the token and puts an Authentication into the SecurityContext. It extends OncePerRequestFilter so it runs exactly once per request.

package in.shopease.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

public class JwtAuthFilter extends OncePerRequestFilter {

    public static final String AUTH_ERROR = "shopease.auth_error";
    private final JwtService jwtService;

    public JwtAuthFilter(JwtService jwtService) {
        this.jwtService = jwtService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (header == null || !header.startsWith("Bearer ")) {
            chain.doFilter(request, response);        // anonymous; rules decide later
            return;
        }
        try {
            Claims claims = jwtService.parse(header.substring(7));
            List<?> roles = claims.get("roles", List.class);
            var authorities = roles.stream().map(r -> new SimpleGrantedAuthority(r.toString())).toList();

            var auth = UsernamePasswordAuthenticationToken.authenticated(claims.getSubject(), null, authorities);
            SecurityContext context = SecurityContextHolder.createEmptyContext();
            context.setAuthentication(auth);
            SecurityContextHolder.setContext(context);
        } catch (ExpiredJwtException e) {
            request.setAttribute(AUTH_ERROR, "Access token expired");
        } catch (JwtException | IllegalArgumentException e) {
            request.setAttribute(AUTH_ERROR, "Invalid access token");
        }
        chain.doFilter(request, response);
    }
}

Note: The filter does not send the error itself. It leaves the request unauthenticated and stores the reason; if the URL needs a login, Spring calls the AuthenticationEntryPoint, which returns 401 with that reason (see the Exception Handling module). Public URLs like GET /api/products still work even with an expired token.

Wiring It into the Filter Chain

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtService jwtService;

    public SecurityConfig(JwtService jwtService) {
        this.jwtService = jwtService;
    }

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)            // token in header, no cookies: no CSRF risk
            .cors(Customizer.withDefaults())                  // see "CORS and CSRF in Practice"
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .httpBasic(AbstractHttpConfigurer::disable)
            .formLogin(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/products/**", "/api/categories/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated())
            .addFilterBefore(new JwtAuthFilter(jwtService), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

Tip: Create the filter with new inside the config instead of marking it @Component. Spring Boot registers every Filter bean as a normal servlet filter too, so a @Component JWT filter would also run outside the security chain.

What STATELESS Means

SettingEffect
SessionCreationPolicy.STATELESSSpring Security never creates an HTTP session and never reads the SecurityContext from one. No JSESSIONID cookie.
Every requestMust carry the token; the filter rebuilds the SecurityContext from it.
ScalingAny server instance can handle any request: no sticky sessions, no shared session store.
Trade-offYou cannot "delete" a token on the server. Keep access tokens short-lived (5–15 min) and use refresh tokens.