WL
Software Engineer
Wassim Lagnaoui

Tutorial 11: API Security with JWT

Token generation, the authentication filter, stateless session policy, and Spring Security configuration.

← Back to Tutorials

The restaurant system is internal: only authenticated staff should access it. This tutorial walks through the JWT security layer: how tokens are generated and validated by JwtService, how each request is intercepted and authenticated by JwtAuthenticationFilter, and how SecurityConfig wires it all together with a stateless session policy.


1. JwtService: token generation and validation

@Service
public class JwtService {

    @Value("${jwt.secret}")
    private String secretKey;

    @Value("${jwt.expiration}")
    private long jwtExpiration; // milliseconds, e.g. 86400000 = 24h

    // Build a signed JWT containing the user's email and role
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> extraClaims = new HashMap<>();

        if (userDetails instanceof User user) {
            extraClaims.put("role", user.getRole().name()); // embed "ROLE_ADMIN" or "ROLE_USER"
        }
        return generateToken(extraClaims, userDetails);
    }

    public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return Jwts.builder()
            .setClaims(extraClaims)
            .setSubject(userDetails.getUsername())          // email = subject
            .setIssuedAt(new Date(System.currentTimeMillis()))
            .setExpiration(new Date(System.currentTimeMillis() + jwtExpiration))
            .signWith(getSignInKey(), SignatureAlgorithm.HS256)
            .compact();
    }

    private Key getSignInKey() {
        return Keys.hmacShaKeyFor(secretKey.getBytes());
    }

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }

    private boolean isTokenExpired(String token) {
        return extractClaim(token, Claims::getExpiration).before(new Date());
    }
}
  • @Value injection: jwt.secret and jwt.expiration are read from application.yml: never hardcoded. The secret stays out of source control.
  • Extra claims: the role is embedded in the token payload so the front end can read it without an extra API call. The role is also re-validated server-side on every request.
  • HS256: symmetric signing algorithm: the same secret is used to sign and verify. For a multi-service architecture, RS256 (asymmetric) is preferred.
  • extractClaim is a generic helper: it parses all claims once and applies a Function<Claims, T> to extract whichever field is needed.
  • isTokenValid checks two things: the username in the token matches the user we loaded from DB, and the token hasn't expired.

2. JwtAuthenticationFilter: intercepting every request

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final CustomUserDetailsService customUserDetailsService;

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

        // 1. Skip JWT check for auth endpoints
        if (request.getServletPath().startsWith("/api/auth")) {
            filterChain.doFilter(request, response);
            return;
        }

        // 2. Extract the Authorization header
        final String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // 3. Extract token and username
        final String jwtToken = authHeader.substring(7); // strip "Bearer "
        final String userEmail = jwtService.extractUsername(jwtToken);

        // 4. If not already authenticated, load and validate
        if (userEmail != null &&
                SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails =
                customUserDetailsService.loadUserByUsername(userEmail);

            if (jwtService.isTokenValid(jwtToken, userDetails)) {
                UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());

                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }

        filterChain.doFilter(request, response);
    }
}
  • OncePerRequestFilter: guarantees the filter runs exactly once per HTTP request, even in forward/include scenarios.
  • Early return for /api/auth: login and register must be reachable without a token: skipping the filter for these paths avoids a chicken-and-egg problem.
  • authHeader.substring(7): strips the "Bearer " prefix (7 characters) to get the raw token string.
  • Authentication == null check: prevents re-processing a request that's already been authenticated by an earlier filter in the chain.
  • SecurityContextHolder.getContext().setAuthentication(authToken): this single line tells Spring Security "this request is authenticated as this user." Every downstream security check reads from this context.
  • No credentials are stored in the auth token (second argument is null): the password is not needed after authentication.

3. SecurityConfig: wiring it all together

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomUserDetailsService customUserDetailsService;
    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager() {
        return new ProviderManager(daoAuthenticationProvider());
    }

    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(customUserDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .cors(withDefaults())
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(
                    "/api/auth/**",
                    "/swagger-ui/**",
                    "/v3/api-docs/**",
                    "/orders/*",
                    "/sessions/*"
                ).permitAll()
                .anyRequest().authenticated()
            )
            .sessionManagement(sess ->
                sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authenticationProvider(daoAuthenticationProvider())
            .addFilterBefore(jwtAuthenticationFilter,
                UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}
  • SessionCreationPolicy.STATELESS: Spring Security will never create an HTTP session. Every request must carry its own JWT: this is the defining trait of a REST API.
  • csrf disabled: CSRF attacks require a session cookie to hijack. Since this API is stateless and uses Bearer tokens (not cookies), CSRF protection is unnecessary.
  • addFilterBefore: inserts the JWT filter before Spring's default UsernamePasswordAuthenticationFilter, so JWT authentication runs first on every request.
  • permitAll paths: auth endpoints, Swagger UI, and the orders/sessions paths are public. Everything else requires a valid JWT.
  • BCryptPasswordEncoder: passwords are hashed with bcrypt before storage. The work factor makes brute-force attacks expensive even if the database is compromised.
  • DaoAuthenticationProvider: the bridge between Spring Security's auth mechanism and your UserDetailsService (which loads users from the DB).

4. The full request flow

// 1. Client sends:
//    POST /api/auth/login  { "email": "...", "password": "..." }

// 2. AuthService validates credentials and returns:
//    { "token": "eyJhbGciOiJIUzI1NiJ9..." }

// 3. Client stores the token and sends on every subsequent request:
//    GET /api/menu-items
//    Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

// 4. JwtAuthenticationFilter intercepts the request:
//    → extracts "Bearer " → raw token
//    → extracts email from claims
//    → loads UserDetails from DB
//    → validates token signature + expiry
//    → sets Authentication in SecurityContext

// 5. Spring Security allows the request through to the controller

Key Takeaways

  • Never hardcode JWT secrets: inject them via @Value from environment-specific config files that are excluded from source control.
  • SessionCreationPolicy.STATELESS is non-negotiable for a JWT REST API: it prevents Spring from creating server-side sessions that contradict the stateless design.
  • The filter sets authentication in the SecurityContext: this single object is what all downstream Spring Security checks read from.
  • Skip the filter for auth endpoints explicitly (/api/auth/**), otherwise login and register are unreachable without a token.
  • BCrypt is the right password hasher: never store plain text or use MD5/SHA-1 for passwords.