Welcome to Chapter 15! In this crucial chapter, we’re going to elevate the “Basic To-Do List Application” you’ve been building by implementing robust security measures. A production-ready application, especially one exposing an API, absolutely requires authentication and authorization to protect its resources from unauthorized access and malicious activity.

We will integrate Spring Security 6, the latest iteration of the powerful security framework for Spring applications, to secure our To-Do API. This involves setting up user authentication using JSON Web Tokens (JWT) for stateless API communication and defining authorization rules to control access to specific endpoints based on user roles. By the end of this chapter, you will have a fully secured To-Do List API, where users must log in to obtain a token, and then use that token to interact with their To-Do items.

Prerequisites

Before diving in, ensure you have completed the previous chapters, particularly the one where we built the “Basic To-Do List Application” with a RESTful API (e.g., Chapter 10 or 11, assuming it’s a Spring Boot API). You should have a working Spring Boot application with endpoints for managing To-Do items (create, read, update, delete). We’ll be using Java 25, Spring Boot 3.x, and Maven for dependency management.

Expected Outcome

Upon completing this chapter, your To-Do List API will:

  • Require authentication for all To-Do related endpoints.
  • Provide a /api/auth/login endpoint for users to authenticate with a username and password.
  • Issue a JWT upon successful login.
  • Validate JWTs included in subsequent requests to grant access to protected resources.
  • Reject requests without a valid JWT with a 401 Unauthorized status.
  • Reject requests from authenticated users without the necessary roles with a 403 Forbidden status.

Planning & Design

Securing an API involves several key architectural components and a clear flow for authentication and authorization.

Component Architecture for Security

Our existing To-Do application architecture will be enhanced with the following Spring Security components:

  1. SecurityConfig: The central configuration class where we define security rules, authentication providers, password encoders, and integrate our custom JWT filters.
  2. UserDetailsService: An interface implemented to load user-specific data during authentication. For this tutorial, we’ll start with an in-memory user store, but in production, this would typically fetch users from a database.
  3. PasswordEncoder: An interface for performing one-way hashing of passwords. We’ll use BCryptPasswordEncoder for strong password hashing.
  4. AuthenticationManager: The core component that handles authentication requests.
  5. JwtUtil: A utility class responsible for generating, validating, and extracting information from JWTs.
  6. JwtAuthenticationFilter: A custom Spring OncePerRequestFilter that intercepts incoming HTTP requests, extracts the JWT from the Authorization header, validates it, and sets the authenticated user in Spring Security’s context.
  7. AuthController: A new REST controller to handle authentication-related endpoints, primarily /api/auth/login.
  8. AuthEntryPointJwt: A custom AuthenticationEntryPoint to handle unauthorized access attempts and return a meaningful HTTP response.

API Endpoints Design for Authentication

We’ll introduce a new endpoint for authentication:

  • POST /api/auth/login:
    • Request Body: JSON object { "username": "...", "password": "..." }
    • Response Body (Success): JSON object { "jwt": "..." } containing the generated JWT.
    • Response Status (Success): 200 OK
    • Response Status (Failure): 401 Unauthorized

All existing To-Do List API endpoints (e.g., /api/todos, /api/todos/{id}) will now require a valid JWT in the Authorization: Bearer <token> header.

File Structure

We will create a new package, com.example.todo.security, to house all security-related classes, and add an auth package within controller for the authentication endpoint.

src/main/java/com/example/todo/
├── TodoApplication.java
├── controller/
│   ├── TodoController.java
│   └── auth/
│       └── AuthController.java
├── model/
│   └── Todo.java
├── repository/
│   └── TodoRepository.java
├── service/
│   └── TodoService.java
└── security/
    ├── jwt/
    │   ├── AuthEntryPointJwt.java
    │   ├── JwtAuthenticationFilter.java
    │   └── JwtUtil.java
    ├── config/
    │   └── SecurityConfig.java
    └── services/
        ├── UserDetailsImpl.java
        └── UserDetailsServiceImpl.java

Step-by-Step Implementation

Let’s begin by adding the necessary dependencies and setting up the core security configuration.

1. Setup: Add Spring Security and JWT Dependencies

First, open your pom.xml file and add the following dependencies. Ensure you are using a recent version compatible with Spring Boot 3.x (which implies Spring Security 6.x).

<!-- pom.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.0</version> <!-- Use latest stable Spring Boot 3.x, e.g., 3.2.0 as of Dec 2025 -->
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>todo-app</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>todo-app</name>
    <description>Demo project for Spring Boot Todo App</description>
    <properties>
        <java.version>25</java.version> <!-- Targeting Java 25 as per requirement -->
    </properties>
    <dependencies>
        <!-- Existing Spring Boot Starters (web, data-jpa, etc.) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!-- Spring Security Starter -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <!-- JWT Dependencies -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.11.5</version> <!-- Use a stable version -->
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.11.5</version> <!-- Use a stable version -->
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.11.5</version> <!-- Use a stable version -->
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

Explanation:

  • spring-boot-starter-security: This pulls in all necessary Spring Security components, including core, web, and configuration utilities.
  • jjwt-api, jjwt-impl, jjwt-jackson: These are the core libraries for working with JSON Web Tokens (JWTs). jjwt-api provides the interfaces, jjwt-impl provides the implementation, and jjwt-jackson handles JSON parsing/serialization. We use 0.11.5 as a widely adopted stable version.

After updating pom.xml, run mvn clean install to download the new dependencies.

2. Core Implementation: Spring Security Configuration

Now, let’s create the main security configuration class. This class will define how our application’s security behaves.

File: src/main/java/com/example/todo/security/config/SecurityConfig.java

package com.example.todo.security.config;

import com.example.todo.security.jwt.AuthEntryPointJwt;
import com.example.todo.security.jwt.JwtAuthenticationFilter;
import com.example.todo.security.services.UserDetailsServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableMethodSecurity // Enables @PreAuthorize, @PostAuthorize, @Secured annotations
public class SecurityConfig {

    private final UserDetailsServiceImpl userDetailsService;
    private final AuthEntryPointJwt unauthorizedHandler;
    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(UserDetailsServiceImpl userDetailsService,
                          AuthEntryPointJwt unauthorizedHandler,
                          JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.userDetailsService = userDetailsService;
        this.unauthorizedHandler = unauthorizedHandler;
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    // Bean for Password Encoder
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // Bean for Authentication Provider
    @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userDetailsService);
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    // Bean for Authentication Manager
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
        return authConfig.getAuthenticationManager();
    }

    // Main Security Filter Chain configuration
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable) // Disable CSRF for stateless APIs
            .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler)) // Custom unauthorized handler
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // Use stateless sessions for JWT
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll() // Allow unauthenticated access to auth endpoints
                .requestMatchers("/api/test/**").permitAll() // Example: allow some test endpoints
                .requestMatchers("/h2-console/**").permitAll() // Allow H2 console access (for development only)
                .anyRequest().authenticated() // All other requests require authentication
            );

        // Add our custom JWT filter before Spring Security's default filter
        http.authenticationProvider(authenticationProvider());
        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

Explanation of SecurityConfig:

  • @Configuration and @EnableMethodSecurity: Marks this class as a Spring configuration and enables annotation-based security (e.g., @PreAuthorize).
  • Dependency Injection: We inject UserDetailsServiceImpl, AuthEntryPointJwt, and JwtAuthenticationFilter which we’ll define shortly.
  • passwordEncoder(): Defines a BCryptPasswordEncoder bean. This is crucial for securely hashing user passwords. Never store plain-text passwords!
  • authenticationProvider(): Configures DaoAuthenticationProvider to use our UserDetailsServiceImpl and PasswordEncoder. This provider will handle authenticating users against our custom user details service.
  • authenticationManager(): Exposes the AuthenticationManager as a bean, which we’ll use in our AuthController to authenticate users.
  • securityFilterChain(): This is the core of Spring Security 6 configuration using the new Lambda DSL.
    • csrf(AbstractHttpConfigurer::disable): CSRF protection is typically disabled for stateless REST APIs because JWTs inherently protect against CSRF attacks.
    • exceptionHandling(...): Configures our AuthEntryPointJwt to handle AuthenticationException (e.g., when an unauthenticated user tries to access a protected resource).
    • sessionManagement(...): Sets SessionCreationPolicy.STATELESS. This tells Spring Security not to create or use HTTP sessions, which is essential for a stateless JWT-based API.
    • authorizeHttpRequests(...): Defines authorization rules:
      • "/api/auth/**": Permits all requests to our authentication endpoints.
      • "/api/test/**": An example of another public endpoint if you have one.
      • "/h2-console/**": Permits access to the H2 database console (for development only; disable or secure in production).
      • anyRequest().authenticated(): All other requests require authentication.
    • http.authenticationProvider(authenticationProvider()): Registers our custom authentication provider.
    • http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class): This is where our custom JwtAuthenticationFilter is plugged into the Spring Security filter chain. It ensures our JWT validation happens before Spring Security’s default username/password filter.

3. Core Implementation: User Details Service

Spring Security needs to know how to load user details. We’ll create two classes for this.

File: src/main/java/com/example/todo/security/services/UserDetailsImpl.java

package com.example.todo.security.services;

import com.fasterxml.jackson.annotation.JsonIgnore;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.io.Serial;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;

public class UserDetailsImpl implements UserDetails {
    @Serial
    private static final long serialVersionUID = 1L;

    private Long id;
    private String username;
    private String email;

    @JsonIgnore
    private String password;

    private Collection<? extends GrantedAuthority> authorities;

    public UserDetailsImpl(Long id, String username, String email, String password,
                           Collection<? extends GrantedAuthority> authorities) {
        this.id = id;
        this.username = username;
        this.email = email;
        this.password = password;
        this.authorities = authorities;
    }

    // Factory method to build UserDetailsImpl from a User entity (we'll simulate a User entity later)
    public static UserDetailsImpl build(Long id, String username, String email, String password, List<String> roles) {
        List<GrantedAuthority> authorities = roles.stream()
                .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) // Spring Security expects roles with "ROLE_" prefix
                .collect(Collectors.toList());

        return new UserDetailsImpl(
                id,
                username,
                email,
                password,
                authorities);
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    public Long getId() {
        return id;
    }

    public String getEmail() {
        return email;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        UserDetailsImpl user = (UserDetailsImpl) o;
        return Objects.equals(id, user.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

Explanation of UserDetailsImpl:

  • This class implements Spring Security’s UserDetails interface. It holds core user information like ID, username, password, email, and authorities (roles).
  • @JsonIgnore on password prevents it from being serialized into JSON responses.
  • The build method is a static factory to easily create UserDetailsImpl instances, converting a list of role strings into GrantedAuthority objects (prefixed with “ROLE_” as per Spring Security convention).
  • All isAccountNonExpired(), isAccountNonLocked(), isCredentialsNonExpired(), and isEnabled() methods return true for simplicity in this tutorial. In a real application, these would be backed by database fields for account management.

File: src/main/java/com/example/todo/security/services/UserDetailsServiceImpl.java

package com.example.todo.security.services;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private static final Logger logger = LoggerFactory.getLogger(UserDetailsServiceImpl.class);

    // In-memory user store for demonstration.
    // In a real application, this would interact with a User entity and a UserRepository.
    private final Map<String, UserDetailsImpl> users = new HashMap<>();
    private final PasswordEncoder passwordEncoder;

    public UserDetailsServiceImpl(PasswordEncoder passwordEncoder) {
        this.passwordEncoder = passwordEncoder;
        // Initialize some dummy users for testing
        initializeUsers();
    }

    private void initializeUsers() {
        // User with "USER" role
        users.put("user", UserDetailsImpl.build(
                1L,
                "user",
                "[email protected]",
                passwordEncoder.encode("password"), // Encode the password
                List.of("USER")
        ));

        // User with "ADMIN" role
        users.put("admin", UserDetailsImpl.build(
                2L,
                "admin",
                "[email protected]",
                passwordEncoder.encode("adminpass"), // Encode the password
                List.of("ADMIN", "USER") // Admin also has USER role
        ));
        logger.info("Initialized in-memory users: {}", users.keySet());
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        logger.debug("Attempting to load user by username: {}", username);
        UserDetailsImpl user = users.get(username);
        if (user == null) {
            logger.warn("User not found with username: {}", username);
            throw new UsernameNotFoundException("User Not Found with username: " + username);
        }
        logger.debug("User '{}' loaded successfully.", username);
        return user;
    }
}

Explanation of UserDetailsServiceImpl:

  • @Service: Marks this class as a Spring service component.
  • UserDetailsService: This interface has one method, loadUserByUsername, which Spring Security calls during authentication to retrieve user details.
  • In-Memory User Store: For this tutorial, we simulate a database by using a HashMap to store user details. We pre-populate it with two users: user (password password, role USER) and admin (password adminpass, roles ADMIN, USER).
  • Password Encoding: Notice passwordEncoder.encode("password"). The passwords stored in our HashMap are hashed using BCryptPasswordEncoder. When a user tries to log in, Spring Security will take their provided password, encode it, and compare it with the stored encoded password.
  • Production Readiness: In a real application, this service would inject a UserRepository and fetch User entities from a database, converting them into UserDetailsImpl instances.

4. Core Implementation: JWT Utility Class

This class will handle the creation and validation of JWTs.

File: src/main/java/com/example/todo/security/jwt/JwtUtil.java

package com.example.todo.security.jwt;

import com.example.todo.security.services.UserDetailsImpl;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Date;

@Component
public class JwtUtil {
    private static final Logger logger = LoggerFactory.getLogger(JwtUtil.class);

    @Value("${todoapp.jwtSecret}")
    private String jwtSecret;

    @Value("${todoapp.jwtExpirationMs}")
    private int jwtExpirationMs;

    // Generate JWT token
    public String generateJwtToken(Authentication authentication) {
        UserDetailsImpl userPrincipal = (UserDetailsImpl) authentication.getPrincipal();

        // Log the user for whom the token is being generated
        logger.info("Generating JWT for user: {}", userPrincipal.getUsername());

        return Jwts.builder()
                .setSubject((userPrincipal.getUsername()))
                .setIssuedAt(new Date())
                .setExpiration(new Date((new Date()).getTime() + jwtExpirationMs))
                .signWith(key(), SignatureAlgorithm.HS512)
                .compact();
    }

    // Get signing key
    private Key key() {
        return Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtSecret));
    }

    // Get username from JWT token
    public String getUserNameFromJwtToken(String token) {
        return Jwts.parserBuilder().setSigningKey(key()).build()
                .parseClaimsJws(token).getBody().getSubject();
    }

    // Validate JWT token
    public boolean validateJwtToken(String authToken) {
        try {
            Jwts.parserBuilder().setSigningKey(key()).build().parse(authToken);
            logger.debug("JWT token is valid.");
            return true;
        } catch (MalformedJwtException e) {
            logger.error("Invalid JWT token: {}", e.getMessage());
        } catch (ExpiredJwtException e) {
            logger.error("JWT token is expired: {}", e.getMessage());
        } catch (UnsupportedJwtException e) {
            logger.error("JWT token is unsupported: {}", e.getMessage());
        } catch (IllegalArgumentException e) {
            logger.error("JWT claims string is empty: {}", e.getMessage());
        } catch (SignatureException e) {
            logger.error("Invalid JWT signature: {}", e.getMessage());
        }
        return false;
    }
}

Explanation of JwtUtil:

  • @Component: Marks this class as a Spring component.
  • @Value annotations: jwtSecret and jwtExpirationMs are loaded from application.properties (we’ll add these next).
  • generateJwtToken(): Takes an Authentication object, extracts UserDetailsImpl, and builds a JWT. It sets the subject (username), issued at date, expiration date, and signs the token with our secret key using HS512 algorithm.
  • key(): Decodes the base64 encoded secret from application.properties to create a Key object for signing/verification.
  • getUserNameFromJwtToken(): Parses the token and extracts the subject (username).
  • validateJwtToken(): Attempts to parse and validate the token using the secret key. It catches various JwtException types and logs them, returning false if validation fails. This robust error handling is crucial for production.

Now, add the JWT configuration properties to your application.properties file:

File: src/main/resources/application.properties

# H2 Database Configuration (for development)
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.datasource.url=jdbc:h2:mem:tododb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update

# Server Port
server.port=8080

# JWT Configuration
todoapp.jwtSecret=YOUR_VERY_STRONG_AND_LONG_SECRET_KEY_HERE_THAT_IS_BASE64_ENCODED_AND_AT_LEAST_256_BITS_LONG_FOR_HS512
todoapp.jwtExpirationMs=86400000 # 24 hours in milliseconds

IMPORTANT:

  • todoapp.jwtSecret: Replace YOUR_VERY_STRONG_AND_LONG_SECRET_KEY_HERE... with a truly strong, randomly generated, base64-encoded secret key. For production, this should be stored securely (e.g., in environment variables, a vault service) and not directly in application.properties. A simple way to generate one is echo 'your_secret_key_string' | base64 (and ensure the string is long enough for HS512, at least 32 characters before base64 encoding).
  • todoapp.jwtExpirationMs: This defines how long the token is valid (here, 24 hours). Adjust as needed.

5. Core Implementation: JWT Authentication Filter

This filter will intercept every request to secured endpoints, extract the JWT, validate it, and set the user’s authentication context.

File: src/main/java/com/example/todo/security/jwt/JwtAuthenticationFilter.java

package com.example.todo.security.jwt;

import com.example.todo.security.services.UserDetailsServiceImpl;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);

    private final JwtUtil jwtUtil;
    private final UserDetailsServiceImpl userDetailsService;

    public JwtAuthenticationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) {
        this.jwtUtil = jwtUtil;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            String jwt = parseJwt(request);
            if (jwt != null && jwtUtil.validateJwtToken(jwt)) {
                String username = jwtUtil.getUserNameFromJwtToken(jwt);

                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                                userDetails,
                                null,
                                userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                SecurityContextHolder.getContext().setAuthentication(authentication);
                logger.debug("Successfully authenticated user: {}", username);
            } else if (jwt != null) {
                logger.warn("JWT validation failed for token: {}", jwt);
            }
        } catch (Exception e) {
            logger.error("Cannot set user authentication: {}", e.getMessage());
        }

        filterChain.doFilter(request, response);
    }

    private String parseJwt(HttpServletRequest request) {
        String headerAuth = request.getHeader("Authorization");

        if (StringUtils.hasText(headerAuth) && headerAuth.startsWith("Bearer ")) {
            return headerAuth.substring(7); // "Bearer ".length() == 7
        }
        return null;
    }
}

Explanation of JwtAuthenticationFilter:

  • @Component: Marks this as a Spring component.
  • OncePerRequestFilter: Ensures that this filter is executed only once per request.
  • doFilterInternal(): The core logic of the filter.
    • It calls parseJwt() to extract the JWT from the Authorization header (e.g., Bearer <token>).
    • If a JWT is found and validated by jwtUtil.validateJwtToken(), it extracts the username.
    • It then loads UserDetails using userDetailsService.loadUserByUsername().
    • A UsernamePasswordAuthenticationToken is created, representing the authenticated user.
    • SecurityContextHolder.getContext().setAuthentication(authentication): This is the critical step. It tells Spring Security that the current request is authenticated under this UserDetails. Subsequent security checks (e.g., @PreAuthorize annotations) will use this context.
    • filterChain.doFilter(request, response): Passes the request to the next filter in the chain.
  • parseJwt(): A helper method to extract the token string from the Authorization header.

6. Core Implementation: Custom Authentication Entry Point

When an unauthenticated user tries to access a secured resource, Spring Security throws an AuthenticationException. This class handles that exception by sending a 401 Unauthorized response.

File: src/main/java/com/example/todo/security/jwt/AuthEntryPointJwt.java

package com.example.todo.security.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@Component
public class AuthEntryPointJwt implements AuthenticationEntryPoint {

    private static final Logger logger = LoggerFactory.getLogger(AuthEntryPointJwt.class);

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
            throws IOException, ServletException {
        logger.error("Unauthorized error: {}", authException.getMessage());

        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // HTTP 401

        final Map<String, Object> body = new HashMap<>();
        body.put("status", HttpServletResponse.SC_UNAUTHORIZED);
        body.put("error", "Unauthorized");
        body.put("message", authException.getMessage());
        body.put("path", request.getServletPath());

        final ObjectMapper mapper = new ObjectMapper();
        mapper.writeValue(response.getOutputStream(), body);
    }
}

Explanation of AuthEntryPointJwt:

  • @Component: Marks this as a Spring component.
  • AuthenticationEntryPoint: This interface is used to send an HTTP response that requests credentials from a client.
  • commence(): This method is invoked when an unauthenticated user attempts to access a protected resource. Instead of redirecting to a login page (common for web apps), we set the response status to 401 Unauthorized and return a JSON error message. This is standard practice for REST APIs.

7. Core Implementation: Authentication Controller

This controller will expose the /api/auth/login endpoint for users to obtain a JWT.

File: src/main/java/com/example/todo/controller/auth/AuthController.java

package com.example.todo.controller.auth;

import com.example.todo.security.jwt.JwtUtil;
import com.example.todo.security.services.UserDetailsImpl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/api/auth")
public class AuthController {
    private static final Logger logger = LoggerFactory.getLogger(AuthController.class);

    private final AuthenticationManager authenticationManager;
    private final JwtUtil jwtUtil;

    public AuthController(AuthenticationManager authenticationManager, JwtUtil jwtUtil) {
        this.authenticationManager = authenticationManager;
        this.jwtUtil = jwtUtil;
    }

    @PostMapping("/login")
    public ResponseEntity<?> authenticateUser(@RequestBody LoginRequest loginRequest) {
        logger.info("Authentication attempt for user: {}", loginRequest.getUsername());
        try {
            Authentication authentication = authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));

            SecurityContextHolder.getContext().setAuthentication(authentication);
            String jwt = jwtUtil.generateJwtToken(authentication);

            UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();

            Map<String, Object> response = new HashMap<>();
            response.put("jwt", jwt);
            response.put("id", userDetails.getId());
            response.put("username", userDetails.getUsername());
            response.put("email", userDetails.getEmail());
            response.put("roles", userDetails.getAuthorities().stream()
                    .map(grantedAuthority -> grantedAuthority.getAuthority().replace("ROLE_", ""))
                    .toList());

            logger.info("User {} authenticated successfully. JWT generated.", userDetails.getUsername());
            return ResponseEntity.ok(response);
        } catch (org.springframework.security.core.AuthenticationException e) {
            logger.warn("Authentication failed for user {}: {}", loginRequest.getUsername(), e.getMessage());
            return ResponseEntity.status(401).body(Map.of("message", "Invalid username or password"));
        } catch (Exception e) {
            logger.error("An unexpected error occurred during authentication for user {}: {}", loginRequest.getUsername(), e.getMessage(), e);
            return ResponseEntity.status(500).body(Map.of("message", "An unexpected error occurred."));
        }
    }
}

// DTO for login request
class LoginRequest {
    private String username;
    private String password;

    // Getters and Setters
    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

Explanation of AuthController:

  • @RestController and @RequestMapping("/api/auth"): Defines this as a REST controller for authentication endpoints.
  • authenticationManager and jwtUtil: Injected dependencies.
  • authenticateUser(@RequestBody LoginRequest loginRequest): This method handles POST requests to /api/auth/login.
    • authenticationManager.authenticate(...): This is the core authentication call. It takes a UsernamePasswordAuthenticationToken (containing the provided username and password) and attempts to authenticate it using our configured DaoAuthenticationProvider and UserDetailsServiceImpl.
    • If authentication is successful, SecurityContextHolder.getContext().setAuthentication(authentication) is called, though this is primarily for the current request’s context, as JWT relies on tokens for subsequent requests.
    • jwtUtil.generateJwtToken(authentication): Generates the JWT.
    • The generated JWT, along with some user details, is returned in the ResponseEntity.
    • Error Handling: A try-catch block handles AuthenticationException (e.g., bad credentials) and other unexpected exceptions, returning appropriate HTTP status codes and error messages. Logging is used to track authentication attempts and failures.

8. Testing This Component: Initial Security Check

At this point, if you try to access any of your existing To-Do List API endpoints (e.g., GET /api/todos), you should receive a 401 Unauthorized response. This indicates that Spring Security is active and protecting your endpoints.

  1. Start your Spring Boot application.

    mvn spring-boot:run
    
  2. Try to access a protected endpoint (e.g., GET /api/todos) without a token:

    curl -v http://localhost:8080/api/todos
    

    Expected Output: You should see an HTTP 401 Unauthorized status code in the response, likely with a JSON body indicating “Unauthorized”.

    {
        "status": 401,
        "error": "Unauthorized",
        "message": "Full authentication is required to access this resource",
        "path": "/api/todos"
    }
    

    This confirms your API is now secured.

9. Core Implementation: Update To-Do Controller with Authorization

Now that we have authentication, let’s ensure our existing To-Do Controller is properly configured to allow authenticated users. We’ll also add a simple test endpoint.

File: src/main/java/com/example/todo/controller/TodoController.java (Assuming you have a basic TodoController from previous chapters. If not, create one with basic CRUD operations.)

package com.example.todo.controller;

import com.example.todo.model.Todo;
import com.example.todo.service.TodoService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Optional;

@RestController
@RequestMapping("/api/todos")
public class TodoController {

    private static final Logger logger = LoggerFactory.getLogger(TodoController.class);

    private final TodoService todoService;

    public TodoController(TodoService todoService) {
        this.todoService = todoService;
    }

    @GetMapping
    @PreAuthorize("hasRole('USER') or hasRole('ADMIN')") // Only authenticated users with USER or ADMIN role
    public ResponseEntity<List<Todo>> getAllTodos() {
        logger.info("Fetching all todos for user: {}", SecurityContextHolder.getContext().getAuthentication().getName());
        List<Todo> todos = todoService.findAll();
        return ResponseEntity.ok(todos);
    }

    @GetMapping("/{id}")
    @PreAuthorize("hasRole('USER') or hasRole('ADMIN')")
    public ResponseEntity<Todo> getTodoById(@PathVariable Long id) {
        logger.info("Fetching todo with ID: {} for user: {}", id, SecurityContextHolder.getContext().getAuthentication().getName());
        Optional<Todo> todo = todoService.findById(id);
        return todo.map(ResponseEntity::ok)
                   .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @PostMapping
    @PreAuthorize("hasRole('USER') or hasRole('ADMIN')")
    public ResponseEntity<Todo> createTodo(@RequestBody Todo todo) {
        logger.info("Creating new todo for user: {}", SecurityContextHolder.getContext().getAuthentication().getName());
        Todo savedTodo = todoService.save(todo);
        return ResponseEntity.status(HttpStatus.CREATED).body(savedTodo);
    }

    @PutMapping("/{id}")
    @PreAuthorize("hasRole('USER') or hasRole('ADMIN')")
    public ResponseEntity<Todo> updateTodo(@PathVariable Long id, @RequestBody Todo todo) {
        logger.info("Updating todo with ID: {} for user: {}", id, SecurityContextHolder.getContext().getAuthentication().getName());
        if (!todoService.findById(id).isPresent()) {
            return ResponseEntity.notFound().build();
        }
        todo.setId(id); // Ensure the ID from path is used
        Todo updatedTodo = todoService.save(todo);
        return ResponseEntity.ok(updatedTodo);
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN')") // Only ADMIN can delete for this example
    public ResponseEntity<Void> deleteTodo(@PathVariable Long id) {
        logger.info("Deleting todo with ID: {} by ADMIN user: {}", id, SecurityContextHolder.getContext().getAuthentication().getName());
        if (!todoService.findById(id).isPresent()) {
            return ResponseEntity.notFound().build();
        }
        todoService.deleteById(id);
        return ResponseEntity.noContent().build();
    }
}

Explanation of TodoController updates:

  • @PreAuthorize("hasRole('USER') or hasRole('ADMIN')"): This annotation ensures that only users with either the USER or ADMIN role can access these methods.
  • @PreAuthorize("hasRole('ADMIN')"): For the deleteTodo endpoint, we’ve restricted access to only ADMIN users, demonstrating fine-grained authorization.
  • SecurityContextHolder.getContext().getAuthentication().getName(): We added logging to show how to retrieve the currently authenticated user’s name from the SecurityContext. This is helpful for auditing and personalized responses.

Let’s add a simple test controller to demonstrate different role access.

File: src/main/java/com/example/todo/controller/TestController.java

package com.example.todo.controller;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/test")
public class TestController {

    private static final Logger logger = LoggerFactory.getLogger(TestController.class);

    @GetMapping("/public")
    public String publicAccess() {
        logger.info("Accessed public endpoint.");
        return "Public Content.";
    }

    @GetMapping("/user")
    @PreAuthorize("hasRole('USER') or hasRole('ADMIN')")
    public String userAccess() {
        logger.info("Accessed user endpoint.");
        return "User Content.";
    }

    @GetMapping("/admin")
    @PreAuthorize("hasRole('ADMIN')")
    public String adminAccess() {
        logger.info("Accessed admin endpoint.");
        return "Admin Content.";
    }
}

Explanation of TestController:

  • /api/test/public: No @PreAuthorize annotation, so it’s publicly accessible (as per SecurityConfig).
  • /api/test/user: Requires USER or ADMIN role.
  • /api/test/admin: Requires ADMIN role.

This controller helps verify our role-based access control (RBAC).

10. Testing & Verification

Now, let’s test the complete authentication and authorization flow.

  1. Ensure your Spring Boot application is running.

    mvn spring-boot:run
    
  2. Access the Public Endpoint:

    curl http://localhost:8080/api/test/public
    

    Expected Output: Public Content. (HTTP 200 OK)

  3. Attempt to access a protected endpoint without authentication:

    curl -v http://localhost:8080/api/todos
    

    Expected Output: 401 Unauthorized

  4. Log in as ‘user’ to get a JWT:

    curl -X POST -H "Content-Type: application/json" -d '{"username":"user", "password":"password"}' http://localhost:8080/api/auth/login
    

    Expected Output: A JSON object containing a JWT and user details. Copy the jwt value.

    {
        "jwt": "eyJhbGciOiJIUzUxMiJ9...",
        "id": 1,
        "username": "user",
        "email": "[email protected]",
        "roles": ["USER"]
    }
    
  5. Access /api/todos with the ‘user’ JWT: (Replace YOUR_JWT_TOKEN with the token you copied)

    curl -H "Authorization: Bearer YOUR_JWT_TOKEN" http://localhost:8080/api/todos
    

    Expected Output: An empty JSON array [] (if no todos exist yet) or a list of todos. (HTTP 200 OK). This confirms authentication and USER role authorization.

  6. Access /api/test/user with the ‘user’ JWT:

    curl -H "Authorization: Bearer YOUR_JWT_TOKEN" http://localhost:8080/api/test/user
    

    Expected Output: User Content. (HTTP 200 OK)

  7. Attempt to access /api/test/admin with the ‘user’ JWT:

    curl -v -H "Authorization: Bearer YOUR_JWT_TOKEN" http://localhost:8080/api/test/admin
    

    Expected Output: 403 Forbidden. This confirms that the user (who only has the USER role) is correctly denied access to the ADMIN endpoint.

  8. Log in as ‘admin’ to get a JWT:

    curl -X POST -H "Content-Type: application/json" -d '{"username":"admin", "password":"adminpass"}' http://localhost:8080/api/auth/login
    

    Expected Output: A JSON object containing a JWT and admin details. Copy the jwt value.

    {
        "jwt": "eyJhbGciOiJIUzUxMiJ9...",
        "id": 2,
        "username": "admin",
        "email": "[email protected]",
        "roles": ["ADMIN", "USER"]
    }
    
  9. Access /api/test/admin with the ‘admin’ JWT: (Replace YOUR_ADMIN_JWT_TOKEN)

    curl -H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN" http://localhost:8080/api/test/admin
    

    Expected Output: Admin Content. (HTTP 200 OK). This confirms the admin user has the necessary role.

  10. Test DELETE /api/todos/{id} with ‘admin’ JWT: First, create a todo using the admin token:

    curl -X POST -H "Content-Type: application/json" -H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN" -d '{"title":"Admin Todo", "description":"Description by admin"}' http://localhost:8080/api/todos
    

    Copy the id from the response (e.g., 1). Then delete it:

    curl -X DELETE -H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN" http://localhost:8080/api/todos/1
    

    Expected Output: HTTP 204 No Content.

You have successfully implemented JWT-based authentication and role-based authorization for your API!


Production Considerations

Securing an API is paramount. While our current implementation is a good start, production environments require additional hardening.

  • Error Handling: Our AuthEntryPointJwt provides a generic 401 Unauthorized response. For specific authentication failures (e.g., account locked, disabled), you might want to return more detailed, but still generic, error messages to prevent information leakage that could aid attackers.
  • Performance Optimization: JWT validation is generally fast. The main performance consideration is the database lookup for UserDetails in UserDetailsServiceImpl. Ensure your user repository queries are optimized and indexed. Consider caching user details if your application experiences high authentication traffic and user data doesn’t change frequently.
  • Security Considerations:
    • JWT Secret Key: NEVER hardcode your JWT secret key in production. It must be stored securely (e.g., environment variables, AWS Secrets Manager, Azure Key Vault, HashiCorp Vault). Rotate keys periodically.
    • HTTPS/TLS: Always deploy your API over HTTPS/TLS. JWTs are signed, but not encrypted by default. Transmitting them over unencrypted HTTP allows eavesdroppers to read the token’s claims, even if they can’t tamper with it. Spring Boot applications can be configured for HTTPS.
    • Password Storage: BCryptPasswordEncoder is a good choice. Ensure you’re using a strong hashing algorithm with sufficient strength (cost factor).
    • Rate Limiting: Implement rate limiting on your /api/auth/login endpoint to prevent brute-force attacks. Spring Cloud Gateway or an API Gateway (like Nginx, Kong) can handle this.
    • CORS: Carefully configure Cross-Origin Resource Sharing (CORS) to only allow trusted front-end applications to interact with your API. Spring Boot provides easy CORS configuration.
    • Logging: Be mindful of what you log. Avoid logging sensitive information like raw passwords or full JWTs (only log truncated versions or hashes if absolutely necessary for debugging).
    • Dependencies: Keep Spring Security and JJWT dependencies updated to patch known vulnerabilities.
  • Logging and Monitoring:
    • Implement robust logging for authentication attempts (success and failure), token generation, and validation failures. Use a structured logging format (e.g., JSON) for easier analysis by monitoring tools.
    • Integrate with monitoring systems (e.g., Prometheus, Grafana, ELK stack, Splunk) to track security events, API access patterns, and potential anomalies.
    • Alert on failed login attempts, unusual token validation errors, or attempts to access unauthorized resources.

Code Review Checkpoint

Let’s review what we’ve accomplished in this chapter:

  • New Dependencies: Added spring-boot-starter-security and jjwt libraries to pom.xml.
  • New Configuration:
    • src/main/java/com/example/todo/security/config/SecurityConfig.java: Configured SecurityFilterChain for stateless JWT authentication, disabled CSRF, defined authorization rules, and integrated custom filters.
    • src/main/resources/application.properties: Added todoapp.jwtSecret and todoapp.jwtExpirationMs.
  • New Security Components:
    • src/main/java/com/example/todo/security/jwt/JwtUtil.java: Utility for JWT creation and validation.
    • src/main/java/com/example/todo/security/jwt/JwtAuthenticationFilter.java: Custom filter to process JWTs in incoming requests.
    • src/main/java/com/example/todo/security/jwt/AuthEntryPointJwt.java: Custom handler for unauthorized access.
    • src/main/java/com/example/todo/security/services/UserDetailsImpl.java: Implementation of Spring Security’s UserDetails.
    • src/main/java/com/example/todo/security/services/UserDetailsServiceImpl.java: Implementation of UserDetailsService with an in-memory user store.
  • New Controller:
    • src/main/java/com/example/todo/controller/auth/AuthController.java: Endpoint for user login and JWT issuance.
  • Modified Controllers:
    • src/main/java/com/example/todo/controller/TodoController.java: Added @PreAuthorize annotations for role-based access control.
    • src/main/java/com/example/todo/controller/TestController.java: Added for demonstrating different access levels.

This comprehensive setup provides a solid foundation for securing your Spring Boot REST APIs using JWT and Spring Security 6.


Common Issues & Solutions

  1. 401 Unauthorized when trying to access /api/auth/login:

    • Issue: This typically means your SecurityFilterChain isn’t configured to permitAll() access to /api/auth/**.
    • Solution: Double-check SecurityConfig.java to ensure auth.requestMatchers("/api/auth/**").permitAll() is correctly specified before anyRequest().authenticated().
    • Debugging: Check application logs for any Spring Security configuration errors or exceptions related to filter chain processing.
  2. 500 Internal Server Error or IllegalArgumentException: JWT claims string is empty during token validation:

    • Issue: This often happens if the todoapp.jwtSecret in application.properties is too short, not base64 encoded, or contains invalid characters. For HS512, it needs to be at least 32 bytes (256 bits) long before base64 encoding.
    • Solution: Generate a strong, long, base64-encoded secret key. For example, in a Linux/macOS terminal: head /dev/urandom | tr -dc A-Za-z0-9_.- | head -c 64 | base64 (this generates a 64-char random string, then base64 encodes it). Copy the output.
    • Debugging: Increase logging level for io.jsonwebtoken to DEBUG in application.properties (logging.level.io.jsonwebtoken=DEBUG) to get more detailed JWT parsing errors.
  3. 403 Forbidden when an authenticated user tries to access a resource:

    • Issue: The user is authenticated (has a valid JWT), but their roles do not match the @PreAuthorize requirements for the specific endpoint.
    • Solution:
      • Verify the roles assigned to the user in UserDetailsServiceImpl (e.g., “USER”, “ADMIN”).
      • Check the @PreAuthorize annotation on the controller method (e.g., hasRole('USER')). Remember that roles in UserDetailsImpl should be prefixed with “ROLE_” (e.g., ROLE_USER), but hasRole() automatically adds this prefix.
      • Ensure the JwtAuthenticationFilter is correctly setting the Authentication object in SecurityContextHolder, including the user’s authorities.
    • Debugging: Log the Authentication object’s authorities after successful authentication in JwtAuthenticationFilter or within the controller method using SecurityContextHolder.getContext().getAuthentication().getAuthorities().

Summary & Next Steps

Congratulations! You’ve successfully implemented a robust security layer for your To-Do List API using Spring Security 6 and JSON Web Tokens. You now have:

  • A clear separation of public and protected API endpoints.
  • User authentication using username and password.
  • JWT generation and validation for stateless API security.
  • Role-based authorization to control access to specific resources.
  • Best practices for password hashing and error handling.

This chapter is a significant step towards a production-ready application, as security is non-negotiable for any real-world service.

In the next chapter, we will continue enhancing our application. We might explore topics like Chapter 16: Implementing Data Persistence with H2 and JPA (if not fully covered yet, or deeper dive into a more robust DB like PostgreSQL) or Chapter 16: Adding Comprehensive API Documentation with OpenAPI (Swagger), which is essential for any API consumed by other services or front-end applications.