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/loginendpoint 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 Unauthorizedstatus. - Reject requests from authenticated users without the necessary roles with a
403 Forbiddenstatus.
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:
SecurityConfig: The central configuration class where we define security rules, authentication providers, password encoders, and integrate our custom JWT filters.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.PasswordEncoder: An interface for performing one-way hashing of passwords. We’ll useBCryptPasswordEncoderfor strong password hashing.AuthenticationManager: The core component that handles authentication requests.JwtUtil: A utility class responsible for generating, validating, and extracting information from JWTs.JwtAuthenticationFilter: A custom SpringOncePerRequestFilterthat intercepts incoming HTTP requests, extracts the JWT from theAuthorizationheader, validates it, and sets the authenticated user in Spring Security’s context.AuthController: A new REST controller to handle authentication-related endpoints, primarily/api/auth/login.AuthEntryPointJwt: A customAuthenticationEntryPointto 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
- Request Body: JSON object
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-apiprovides the interfaces,jjwt-implprovides the implementation, andjjwt-jacksonhandles JSON parsing/serialization. We use0.11.5as 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:
@Configurationand@EnableMethodSecurity: Marks this class as a Spring configuration and enables annotation-based security (e.g.,@PreAuthorize).- Dependency Injection: We inject
UserDetailsServiceImpl,AuthEntryPointJwt, andJwtAuthenticationFilterwhich we’ll define shortly. passwordEncoder(): Defines aBCryptPasswordEncoderbean. This is crucial for securely hashing user passwords. Never store plain-text passwords!authenticationProvider(): ConfiguresDaoAuthenticationProviderto use ourUserDetailsServiceImplandPasswordEncoder. This provider will handle authenticating users against our custom user details service.authenticationManager(): Exposes theAuthenticationManageras a bean, which we’ll use in ourAuthControllerto 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 ourAuthEntryPointJwtto handleAuthenticationException(e.g., when an unauthenticated user tries to access a protected resource).sessionManagement(...): SetsSessionCreationPolicy.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 customJwtAuthenticationFilteris 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
UserDetailsinterface. It holds core user information like ID, username, password, email, and authorities (roles). @JsonIgnoreon password prevents it from being serialized into JSON responses.- The
buildmethod is a static factory to easily createUserDetailsImplinstances, converting a list of role strings intoGrantedAuthorityobjects (prefixed with “ROLE_” as per Spring Security convention). - All
isAccountNonExpired(),isAccountNonLocked(),isCredentialsNonExpired(), andisEnabled()methods returntruefor 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",
"user@example.com",
passwordEncoder.encode("password"), // Encode the password
List.of("USER")
));
// User with "ADMIN" role
users.put("admin", UserDetailsImpl.build(
2L,
"admin",
"admin@example.com",
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
HashMapto store user details. We pre-populate it with two users:user(passwordpassword, roleUSER) andadmin(passwordadminpass, rolesADMIN,USER). - Password Encoding: Notice
passwordEncoder.encode("password"). The passwords stored in ourHashMapare hashed usingBCryptPasswordEncoder. 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
UserRepositoryand fetchUserentities from a database, converting them intoUserDetailsImplinstances.
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.@Valueannotations:jwtSecretandjwtExpirationMsare loaded fromapplication.properties(we’ll add these next).generateJwtToken(): Takes anAuthenticationobject, extractsUserDetailsImpl, 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 fromapplication.propertiesto create aKeyobject 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 variousJwtExceptiontypes and logs them, returningfalseif 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: ReplaceYOUR_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 inapplication.properties. A simple way to generate one isecho '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 theAuthorizationheader (e.g.,Bearer <token>). - If a JWT is found and validated by
jwtUtil.validateJwtToken(), it extracts the username. - It then loads
UserDetailsusinguserDetailsService.loadUserByUsername(). - A
UsernamePasswordAuthenticationTokenis 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 thisUserDetails. Subsequent security checks (e.g.,@PreAuthorizeannotations) will use this context.filterChain.doFilter(request, response): Passes the request to the next filter in the chain.
- It calls
parseJwt(): A helper method to extract the token string from theAuthorizationheader.
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 to401 Unauthorizedand 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:
@RestControllerand@RequestMapping("/api/auth"): Defines this as a REST controller for authentication endpoints.authenticationManagerandjwtUtil: Injected dependencies.authenticateUser(@RequestBody LoginRequest loginRequest): This method handlesPOSTrequests to/api/auth/login.authenticationManager.authenticate(...): This is the core authentication call. It takes aUsernamePasswordAuthenticationToken(containing the provided username and password) and attempts to authenticate it using our configuredDaoAuthenticationProviderandUserDetailsServiceImpl.- 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-catchblock handlesAuthenticationException(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.
Start your Spring Boot application.
mvn spring-boot:runTry to access a protected endpoint (e.g.,
GET /api/todos) without a token:curl -v http://localhost:8080/api/todosExpected Output: You should see an HTTP
401 Unauthorizedstatus 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 theUSERorADMINrole can access these methods.@PreAuthorize("hasRole('ADMIN')"): For thedeleteTodoendpoint, we’ve restricted access to onlyADMINusers, demonstrating fine-grained authorization.SecurityContextHolder.getContext().getAuthentication().getName(): We added logging to show how to retrieve the currently authenticated user’s name from theSecurityContext. 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@PreAuthorizeannotation, so it’s publicly accessible (as perSecurityConfig)./api/test/user: RequiresUSERorADMINrole./api/test/admin: RequiresADMINrole.
This controller helps verify our role-based access control (RBAC).
10. Testing & Verification
Now, let’s test the complete authentication and authorization flow.
Ensure your Spring Boot application is running.
mvn spring-boot:runAccess the Public Endpoint:
curl http://localhost:8080/api/test/publicExpected Output:
Public Content.(HTTP 200 OK)Attempt to access a protected endpoint without authentication:
curl -v http://localhost:8080/api/todosExpected Output:
401 UnauthorizedLog 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/loginExpected Output: A JSON object containing a JWT and user details. Copy the
jwtvalue.{ "jwt": "eyJhbGciOiJIUzUxMiJ9...", "id": 1, "username": "user", "email": "user@example.com", "roles": ["USER"] }Access
/api/todoswith the ‘user’ JWT: (ReplaceYOUR_JWT_TOKENwith the token you copied)curl -H "Authorization: Bearer YOUR_JWT_TOKEN" http://localhost:8080/api/todosExpected Output: An empty JSON array
[](if no todos exist yet) or a list of todos. (HTTP 200 OK). This confirms authentication andUSERrole authorization.Access
/api/test/userwith the ‘user’ JWT:curl -H "Authorization: Bearer YOUR_JWT_TOKEN" http://localhost:8080/api/test/userExpected Output:
User Content.(HTTP 200 OK)Attempt to access
/api/test/adminwith the ‘user’ JWT:curl -v -H "Authorization: Bearer YOUR_JWT_TOKEN" http://localhost:8080/api/test/adminExpected Output:
403 Forbidden. This confirms that theuser(who only has theUSERrole) is correctly denied access to theADMINendpoint.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/loginExpected Output: A JSON object containing a JWT and admin details. Copy the
jwtvalue.{ "jwt": "eyJhbGciOiJIUzUxMiJ9...", "id": 2, "username": "admin", "email": "admin@example.com", "roles": ["ADMIN", "USER"] }Access
/api/test/adminwith the ‘admin’ JWT: (ReplaceYOUR_ADMIN_JWT_TOKEN)curl -H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN" http://localhost:8080/api/test/adminExpected Output:
Admin Content.(HTTP 200 OK). This confirms theadminuser has the necessary role.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/todosCopy the
idfrom the response (e.g.,1). Then delete it:curl -X DELETE -H "Authorization: Bearer YOUR_ADMIN_JWT_TOKEN" http://localhost:8080/api/todos/1Expected 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
AuthEntryPointJwtprovides a generic401 Unauthorizedresponse. 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
UserDetailsinUserDetailsServiceImpl. 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:
BCryptPasswordEncoderis 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/loginendpoint 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-securityandjjwtlibraries topom.xml. - New Configuration:
src/main/java/com/example/todo/security/config/SecurityConfig.java: ConfiguredSecurityFilterChainfor stateless JWT authentication, disabled CSRF, defined authorization rules, and integrated custom filters.src/main/resources/application.properties: Addedtodoapp.jwtSecretandtodoapp.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’sUserDetails.src/main/java/com/example/todo/security/services/UserDetailsServiceImpl.java: Implementation ofUserDetailsServicewith 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@PreAuthorizeannotations 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
401 Unauthorizedwhen trying to access/api/auth/login:- Issue: This typically means your
SecurityFilterChainisn’t configured topermitAll()access to/api/auth/**. - Solution: Double-check
SecurityConfig.javato ensureauth.requestMatchers("/api/auth/**").permitAll()is correctly specified beforeanyRequest().authenticated(). - Debugging: Check application logs for any Spring Security configuration errors or exceptions related to filter chain processing.
- Issue: This typically means your
500 Internal Server ErrororIllegalArgumentException: JWT claims string is emptyduring token validation:- Issue: This often happens if the
todoapp.jwtSecretinapplication.propertiesis 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.jsonwebtokentoDEBUGinapplication.properties(logging.level.io.jsonwebtoken=DEBUG) to get more detailed JWT parsing errors.
- Issue: This often happens if the
403 Forbiddenwhen an authenticated user tries to access a resource:- Issue: The user is authenticated (has a valid JWT), but their roles do not match the
@PreAuthorizerequirements for the specific endpoint. - Solution:
- Verify the roles assigned to the user in
UserDetailsServiceImpl(e.g., “USER”, “ADMIN”). - Check the
@PreAuthorizeannotation on the controller method (e.g.,hasRole('USER')). Remember that roles inUserDetailsImplshould be prefixed with “ROLE_” (e.g.,ROLE_USER), buthasRole()automatically adds this prefix. - Ensure the
JwtAuthenticationFilteris correctly setting theAuthenticationobject inSecurityContextHolder, including the user’s authorities.
- Verify the roles assigned to the user in
- Debugging: Log the
Authenticationobject’s authorities after successful authentication inJwtAuthenticationFilteror within the controller method usingSecurityContextHolder.getContext().getAuthentication().getAuthorities().
- Issue: The user is authenticated (has a valid JWT), but their roles do not match the
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.