JWT with Spring Security

A complete stateless JWT authentication flow — issuing a token on login and validating it with a custom filter.

Why JWT for a stateless API

A traditional session-based login stores authentication state on the server (a session, tied to a cookie) — every request after login relies on the server remembering who you are. That doesn't scale cleanly across multiple server instances without a shared session store, and it doesn't fit non-browser clients well.

A JSON Web Token (JWT) flips this: after a successful login, the server issues a signed token containing the user's identity (and optionally roles), and the client sends that token back on every subsequent request, typically as an Authorization: Bearer <token> header. The server verifies the token's signature and reads the claims directly — no server-side session storage needed at all. This is what "stateless authentication" means in practice.

Plaintext
1. POST /auth/login {email, password}
       │
       v
   Server verifies credentials, issues a signed JWT
       │
       v
2. Client stores the token, sends it on every future request:
       Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
       │
       v
3. A custom filter on the server validates the token's signature + expiry,
   and sets the SecurityContext for that request — no session lookup involved

Issuing a token on login

Java
@Service
public class JwtService {

    private final SecretKey key;
    private final long expirationMillis = 1000 * 60 * 60; // 1 hour

    public JwtService(@Value("${jwt.secret}") String secret) {
        this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    public String generateToken(String email, List<String> roles) {
        return Jwts.builder()
            .subject(email)
            .claim("roles", roles)
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + expirationMillis))
            .signWith(key)
            .compact();
    }

    public String extractEmail(String token) {
        return parseClaims(token).getSubject();
    }

    public boolean isValid(String token) {
        try {
            parseClaims(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }

    private Claims parseClaims(String token) {
        return Jwts.parser()
            .verifyWith(key)
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }
}
Java
@RestController
@RequestMapping("/auth")
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final JwtService jwtService;

    public AuthController(AuthenticationManager authenticationManager, JwtService jwtService) {
        this.authenticationManager = authenticationManager;
        this.jwtService = jwtService;
    }

    @PostMapping("/login")
    public ResponseEntity<Map<String, String>> login(@RequestBody LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(request.email(), request.password())
        );

        List<String> roles = authentication.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority)
            .toList();

        String token = jwtService.generateToken(request.email(), roles);
        return ResponseEntity.ok(Map.of("token", token));
    }
}

record LoginRequest(String email, String password) {}

authenticationManager.authenticate(...) is where the actual credential check happens — it delegates to the UserDetailsService and PasswordEncoder beans described earlier, and throws AuthenticationException (translated by Spring Security into a 401) on bad credentials.

Validating the token on every subsequent request

A custom filter reads the Authorization header, validates the token, and — if valid — populates the SecurityContext so the rest of the filter chain (and @PreAuthorize checks) see an authenticated request:

Java
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final AppUserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtService jwtService, AppUserDetailsService userDetailsService) {
        this.jwtService = jwtService;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain
    ) throws ServletException, IOException {

        String header = request.getHeader("Authorization");

        if (header == null || !header.startsWith("Bearer ")) {
            filterChain.doFilter(request, response); // no token — let later filters/authorization decide
            return;
        }

        String token = header.substring(7);

        if (jwtService.isValid(token)) {
            String email = jwtService.extractEmail(token);
            UserDetails userDetails = userDetailsService.loadUserByUsername(email);

            var authentication = new UsernamePasswordAuthenticationToken(
                userDetails, null, userDetails.getAuthorities()
            );
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }
}

Registering the filter in the SecurityFilterChain, ahead of Spring Security's built-in username/password filter, and disabling session creation entirely since there's no session to maintain:

Java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable()) // no cookies involved, so CSRF protection doesn't apply
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

SessionCreationPolicy.STATELESS tells Spring Security never to create or use an HttpSession — every request must carry its own proof of identity (the JWT), which is the whole point of a stateless design.

Common mistakes

  • Storing the JWT signing secret in source code instead of externalized configuration/secrets management — anyone with the secret can forge valid tokens.
  • Setting no expiration (or an excessively long one) on issued tokens — a stolen token with no expiry is valid forever, since there's no server-side session to revoke.
  • Putting sensitive data (passwords, full profile details) inside JWT claims — the payload is only signed, not encrypted, and is trivially readable by anyone who has the token.
  • Forgetting SessionCreationPolicy.STATELESS, which leaves the app creating sessions anyway alongside JWT validation — inconsistent and unnecessary for a genuinely stateless design.