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
| Setting | Effect |
|---|---|
SessionCreationPolicy.STATELESS | Spring Security never creates an HTTP session and never reads the SecurityContext from one. No JSESSIONID cookie. |
| Every request | Must carry the token; the filter rebuilds the SecurityContext from it. |
| Scaling | Any server instance can handle any request: no sticky sessions, no shared session store. |
| Trade-off | You cannot "delete" a token on the server. Keep access tokens short-lived (5–15 min) and use refresh tokens. |