JWT-Authenticated REST API in Spring Boot
Build a secure REST API with JWT auth, RBAC, and PostgreSQL in Spring Boot.
Introduction
30 Second Summary
Every time you log in to an app, something behind the scenes proves who you are on every single click. That "something" is usually a token. A small, signed piece of data that rides along with every request so the server never has to remember you between calls.
In this project, you will build a REST API with Spring Boot that handles user registration, login, and token-based authentication from scratch. You will wire it to a PostgreSQL database, enforce role-based access control, and write integration tests that prove the whole flow works.
What You'll Build
By the end, you can register a user, log in, receive a signed token, and hit a protected endpoint that only responds if the token checks out. All from your terminal, all stateless, all testable.
By the end of this project, you'll have:
- A working registration and login flow where you POST credentials from curl and receive a signed JWT containing the user's role. Paste it into jwt.io and see your claims decoded on screen.
- Role-based access control that blocks a USER token from admin-only endpoints with a 403 Forbidden response, while an ADMIN token returns the full user list.
- Clean error handling and input validation that returns structured JSON error messages when requests are malformed. No raw stack traces leaking to clients.
- Secret Mission: Harden your API with OWASP security headers and a rate limiter that blocks brute-force login attempts after repeated failures.
Are there any prerequisites?
You should be comfortable with Java fundamentals (OOP, collections, basic programming) and have a general familiarity with HTTP methods and JSON. Git basics are helpful for version control as you go.
No prior Spring Boot or security experience is needed. The project teaches both from the ground up.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. This step is your chance to commit to the goal before any code hits the screen.
Set Up Your Environment
Every backend service needs two things before you write a line of application code: a database to store data and a project skeleton with the right dependencies wired together.
In this step, you will install Docker to run a disposable PostgreSQL instance, generate a Spring Boot project with all the security and data dependencies pre-configured, and connect the two so your app starts cleanly.
In this step, get ready to:
- Install Docker Engine and verify it runs without sudo.
- Generate a Spring Boot 3.5.x project with the correct dependencies.
- Start a PostgreSQL container and connect your app to it.
Install Docker Engine on Fedora
Docker lets you spin up a PostgreSQL database in one command, with no system-wide install or configuration files to manage.
- Open your terminal.
- Add the official Docker repository to your system by running this command:
sudo dnf config-manager addrepo --from-repofile https://download.docker.com/linux/fedora/docker-ce.repo
- Install Docker Engine and its companion tools by running:
sudo dnf install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
What does this install?
This pulls in the Docker daemon (docker-ce), the CLI (docker-ce-cli), the container runtime (containerd.io), and plugins for building images and running multi-container setups.
- Start the Docker daemon and enable it to launch on boot by running:
sudo systemctl enable --now docker
- Verify Docker is working by running a test container:
sudo docker run hello-world
You should see a message that starts with Hello from Docker! followed by an explanation of what just happened.
Don't see Hello from Docker?
- Make sure the daemon is running with sudo systemctl status docker. Look for "active (running)" in the output.
- If you see a permissions error, double-check you included sudo before the docker command.
- help me troubleshoot Docker installation on Fedora
Now remove the need to type sudo before every Docker command.
- Add your user to the docker group by running these commands:
sudo groupadd docker
sudo usermod -aG docker $USER
newgrp docker
- Confirm it worked by running without sudo:
docker run hello-world
You should see the same Hello from Docker! message, this time without needing sudo.
Still getting permission denied?
- The newgrp docker command only applies to your current terminal session. If it does not work, log out and log back in to your system, then try again.
- help me fix Docker permission denied after adding my user to the docker group
Generate a Spring Boot project
IntelliJ IDEA Ultimate has a built-in Spring Initializr wizard that generates a project with your chosen dependencies already wired into the build file.
- In IntelliJ IDEA, click File in the top menu bar.
- Click New, then Project.
- In the left panel of the New Project dialog, select Spring Boot.
- Set the following project details:
- Click Next to move to the dependencies page.
- Select the Spring Boot version 3.5.x (the latest available in the 3.5 series).
- Search for and add these five dependencies:
- Click Create.
IntelliJ generates the project structure and downloads all dependencies. Wait for the indexing progress bar in the bottom status bar to finish.
What did the wizard just create?
Spring Initializr produced a standard Maven project with a pom.xml that declares your five starters, a main class with the @SpringBootApplication annotation, and an empty application.properties file in src/main/resources/.
The project also includes a Maven Wrapper (./mvnw) so you can build and run without installing Maven globally.
Before you connect a database, try starting the app to see what happens when Spring Boot has database dependencies but no configuration.
- Open the terminal inside IntelliJ (View > Tool Windows > Terminal).
- Before you run this, what do you think will happen? The project has Spring Data JPA and a PostgreSQL driver on the classpath but no database URL configured.
- Attempt to start the application by running:
./mvnw spring-boot:run
The application fails to start. You will see an error block containing:
Failed to configure a DataSource: 'url' attribute is not specified and no embedded datasource could be configured.
Why did this fail?
Spring Boot detected spring-boot-starter-data-jpa on the classpath and tried to auto-configure a DataSource. Without a spring.datasource.url property and no embedded database (like H2), it refuses to start.
This is exactly the problem the next substep solves. You will start a PostgreSQL container and tell Spring Boot how to connect to it.
- Press Ctrl+C in the terminal to stop the failed process.
Start PostgreSQL and connect your app
You need a running PostgreSQL instance and a configuration file that tells Spring Boot where to find it.
- Start a PostgreSQL container by running this command:
docker run --name auth-db -e POSTGRES_DB=authdb -e POSTGRES_USER=authuser -e POSTGRES_PASSWORD=authpass -p 5432:5432 -d postgres:17
What does this command do?
- --name auth-db gives the container a memorable name you can reference later.
- -e POSTGRES_DB=authdb creates a database called authdb on first start.
- -e POSTGRES_USER=authuser and -e POSTGRES_PASSWORD=authpass set the login credentials.
- -p 5432:5432 maps the container's PostgreSQL port to your machine so Spring Boot can connect via localhost:5432.
- -d postgres:17 runs PostgreSQL 17 in the background.
- Verify the container is running:
docker ps
You should see a row with auth-db in the NAMES column and postgres:17 in the IMAGE column.
Don't see the container?
- Run docker ps -a to see all containers including stopped ones. If auth-db shows with status "Exited", another process may already be using port 5432.
- Check for port conflicts with ss -tlnp | grep 5432. If something else is listening, stop it before re-running the docker command.
- help me troubleshoot why my Docker PostgreSQL container exits immediately
Now create the configuration file that tells Spring Boot how to connect to this database.
- In the IntelliJ Project panel on the left, expand src > main > resources.
- Delete the existing application.properties file (right-click > Delete).
- Right-click the resources folder, select New > File, and name it application.yml.
- Paste the following configuration into application.yml:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/authdb
username: authuser
password: authpass
jpa:
hibernate:
ddl-auto: update
show-sql: true
server:
port: 8080
What does this configuration do?
- spring.datasource.url points Spring Boot at the PostgreSQL container you just started.
- spring.jpa.hibernate.ddl-auto: update tells Hibernate to create or modify database tables automatically based on your entity classes.
- show-sql: true prints every SQL query to the console so you can see what Hibernate is doing.
- server.port: 8080 makes the API available at http://localhost:8080.
- Save application.yml with Ctrl+S.
- What do you expect to happen now that Spring Boot has a valid datasource URL and a running PostgreSQL container?
- Run the application again:
./mvnw spring-boot:run
This time the application starts successfully. Look for a line near the end of the output that says:
Started AuthApiApplication in X.XXX seconds
You should also see HikariPool-1 - Start completed. which confirms the database connection pool is active.
Application still failing to start?
- If you see a connection refused error, make sure your auth-db container is still running with docker ps. Start it with docker start auth-db if it stopped.
- If you see a YAML parsing error, check that your indentation uses spaces (not tabs) and that each nested key is exactly 2 spaces deeper than its parent.
- Verify the file is named application.yml (not application.yaml or application.properties) and sits directly inside src/main/resources/.
- help me fix my Spring Boot app failing to connect to PostgreSQL in Docker
✔️ Awesome, I've got everything!
Great. Make sure you have saved application.yml and that the application is running without errors.
ⓧ I'd like to double check the full code
Your src/main/resources/application.yml should look exactly like this:
spring:
datasource:
url: jdbc:postgresql://localhost:5432/authdb
username: authuser
password: authpass
jpa:
hibernate:
ddl-auto: update
show-sql: true
server:
port: 8080
Your development environment is ready. You have Docker running PostgreSQL, a Spring Boot project with all the right dependencies, and a confirmed connection between the two. Next up, you will create the User entity and build the registration endpoint so you can store users in this database.
Create the User Entity and Registration Endpoint
Your Spring Boot application starts cleanly, but it does nothing useful yet. Before you can authenticate anyone, you need users stored in a database.
This step produces the first visible result of your API: you will POST a registration request to an endpoint and see a user row appear in PostgreSQL. You will also write an integration test that proves the endpoint works in an isolated environment.
In this step, get ready to:
- Design a User entity and persist it to PostgreSQL with Spring Data JPA.
- Create a registration endpoint that hashes passwords with BCrypt and returns a safe response.
- Write an integration test using Testcontainers to verify the registration flow.
Design and implement the User entity
Every user in the system needs an identity (email), a secret (password), and a permission level (role). You will model this as a JPA entity that maps directly to a users table in PostgreSQL.
- In IntelliJ, create a new package called model inside your main source package (e.g. com.nextwork.auth.model).
- Inside model, create a new Java file called Role.java with the following enum:
package com.nextwork.auth.model;
// Defines the two permission levels in the system
public enum Role {
USER,
ADMIN
}
What does this code do?
This enum defines two roles your API will support. Later, you will use these roles to restrict which endpoints a user can access.
- In the same model package, create a new file called User.java with this entity:
package com.nextwork.auth.model;
import jakarta.persistence.*;
// Maps this class to the "users" table in PostgreSQL
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
// Required no-arg constructor for JPA
public User() {}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
What does this code do?
The @Entity annotation tells JPA this class represents a database table. @Table(name = "users") names the table explicitly because "user" is a reserved keyword in PostgreSQL.
The @Id and @GeneratedValue annotations create an auto-incrementing primary key. @Column(unique = true) on email prevents duplicate registrations at the database level.
- Add the remaining getters and setters directly below the code you just wrote:
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public Role getRole() { return role; }
public void setRole(Role role) { this.role = role; }
}
- Now create a new package called repository and add a file called UserRepository.java:
package com.nextwork.auth.repository;
import com.nextwork.auth.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
// Spring Data JPA generates the SQL queries for you
public interface UserRepository extends JpaRepository<User, Long> {
// Used later to look up users by email during login
Optional<User> findByEmail(String email);
}
What does this code do?
By extending JpaRepository, Spring automatically provides methods like save(), findAll(), and deleteById() without writing any SQL.
The custom findByEmail method uses Spring Data's query derivation. Spring reads the method name and builds a SELECT * FROM users WHERE email = ? query for you.
The password field is stored in the entity, but it must never leak into API responses. You will solve this with dedicated DTOs (Data Transfer Objects) that control exactly which fields get serialized to JSON.
- Create a new package called dto and add a file called RegisterRequest.java:
package com.nextwork.auth.dto;
// Carries the incoming registration data from the client
public class RegisterRequest {
private String email;
private String password;
private String role;
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
}
- In the same dto package, create UserResponse.java:
package com.nextwork.auth.dto;
// Only exposes safe fields (id and email) in API responses
public class UserResponse {
private Long id;
private String email;
public UserResponse(Long id, String email) {
this.id = id;
this.email = email;
}
public Long getId() { return id; }
public String getEmail() { return email; }
}
Why use a separate response DTO?
If you returned the User entity directly, Jackson would serialize every field, including the hashed password. A dedicated UserResponse DTO guarantees the password never appears in API output, regardless of how the entity evolves later.
- Save all five files (Role.java, User.java, UserRepository.java, RegisterRequest.java, UserResponse.java).
- Restart the application by running:
./mvnw spring-boot:run
You should see Hibernate log a CREATE TABLE statement in the console output thanks to ddl-auto: update in your application.yml. This confirms JPA mapped your entity to a real database table.
Application fails to start?
Check that your auth-db Docker container is still running with docker ps. If it is not listed, restart it with docker start auth-db.
If you see a compilation error about jakarta.persistence, confirm spring-boot-starter-data-jpa is in your pom.xml dependencies.
Help me debug my Spring Boot startup error
- Stop the application with Ctrl+C so you can continue coding.
Create the registration endpoint and service
The entity and repository handle persistence. Now you need a service to hash passwords and a controller to expose the registration endpoint over HTTP.
- Create a new package called service and add a file called AuthService.java. Start with the class declaration and constructor:
package com.nextwork.auth.service;
import com.nextwork.auth.dto.RegisterRequest;
import com.nextwork.auth.dto.UserResponse;
import com.nextwork.auth.model.Role;
import com.nextwork.auth.model.User;
import com.nextwork.auth.repository.UserRepository;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class AuthService {
private final UserRepository userRepository;
private final BCryptPasswordEncoder passwordEncoder;
public AuthService(UserRepository userRepository,
BCryptPasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
- Add the registration method directly below the constructor:
// Hashes the password and persists the new user
public UserResponse registerUser(RegisterRequest request) {
User user = new User();
user.setEmail(request.getEmail());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setRole(Role.valueOf(request.getRole()));
User saved = userRepository.save(user);
return new UserResponse(saved.getId(), saved.getEmail());
}
}
What does this code do?
The BCryptPasswordEncoder produces a one-way hash of the raw password. Even if the database is compromised, the original passwords remain unrecoverable.
The service returns a UserResponse instead of the User entity, ensuring the hashed password never reaches the client.
- Create a new package called controller and add AuthController.java:
package com.nextwork.auth.controller;
import com.nextwork.auth.dto.RegisterRequest;
import com.nextwork.auth.dto.UserResponse;
import com.nextwork.auth.service.AuthService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
// POST /api/auth/register
@PostMapping("/register")
public ResponseEntity<UserResponse> register(
@RequestBody RegisterRequest request) {
UserResponse response = authService.registerUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}
What does this code do?
The @RestController annotation marks this class as an HTTP handler that returns JSON. @RequestMapping("/api/auth") prefixes all routes in this controller.
The register method accepts a JSON body, delegates to AuthService, and returns HTTP 201 Created with the user's id and email.
Spring Security blocks all requests by default. You need to temporarily open access so you can test registration before implementing authentication in a later step.
- Create a new package called config and add SecurityConfig.java:
package com.nextwork.auth.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
// Temporarily permits all requests (locked down in Step 5)
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll()
);
return http.build();
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
What does this code do?
The SecurityFilterChain bean replaces Spring Security's default "block everything" behaviour. Right now it permits all requests so you can test freely. You will tighten this configuration in Step 5 when you add JWT verification.
The BCryptPasswordEncoder bean is injected into AuthService to hash passwords before they reach the database.
- Save all three files (AuthService.java, AuthController.java, SecurityConfig.java).
- Restart the application by running:
./mvnw spring-boot:run
Before you test, what do you think will happen if you send a valid registration request? Will you get the user's password back in the response?
- Open a second terminal and register your first user by running:
curl -X POST http://localhost:8080/api/auth/register -H "Content-Type: application/json" -d '{"email":"test@example.com","password":"SecurePass1!","role":"USER"}'
You should see a JSON response with an id and email field, but no password. The HTTP status is 201 Created.
- Verify the user exists in PostgreSQL by running:
docker exec -it auth-db psql -U authuser -d authdb -c "SELECT id, email, role FROM users;"
You should see a single row with the email test@example.com and role USER.
Getting 403 Forbidden?
Spring Security may not have picked up your SecurityConfig class. Ensure SecurityConfig.java is in a package that Spring scans (a subpackage of your main application class).
Also confirm you have the @Configuration annotation on the class and @Bean on the filterChain method.
Help me fix my 403 Forbidden error on registration
Write an integration test with Testcontainers
Manual curl tests prove the endpoint works on your machine. An integration test proves it works repeatably, against a real PostgreSQL instance, in any environment.
- Add the Testcontainers dependencies to your pom.xml inside the existing <dependencies> block:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
What does this code do?
These three dependencies enable Testcontainers in your test suite. Spring Boot manages the versions through its BOM, so you do not need to specify version numbers.
Testcontainers spins up a real PostgreSQL Docker container just for your test run, then tears it down automatically. This gives you confidence your code works against the same database engine you use in development.
- In IntelliJ, reload your Maven project so the new dependencies are downloaded. Click the Maven tool window icon (usually on the right sidebar), then click the Reload All Maven Projects button.
- Under src/test/java, create the test class AuthRegistrationIntegrationTest.java in your base test package. Add the class setup with the container declaration:
package com.nextwork.auth;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class AuthRegistrationIntegrationTest {
// Spins up a disposable PostgreSQL container for this test
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:17");
What does this code do?
The @Testcontainers annotation tells JUnit to manage container lifecycle. The @Container field starts a fresh PostgreSQL 17 instance before the test class runs.
- Add the dynamic property source and test method directly below the container declaration:
// Points Spring's datasource to the test container
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private MockMvc mockMvc;
@Test
void registerUser_returnsCreatedWithIdAndEmail() throws Exception {
String requestBody = """
{"email":"test@example.com","password":"SecurePass1!","role":"USER"}
""";
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.email").value("test@example.com"))
.andExpect(jsonPath("$.password").doesNotExist());
}
}
What does this code do?
The @DynamicPropertySource method overrides your application.yml datasource settings at runtime, so the test connects to the throwaway container instead of your development database.
MockMvc simulates HTTP requests without starting a real web server. The test sends a POST with a JSON body and asserts: HTTP 201 status, an id field exists, and the email matches. The jsonPath("$.password").doesNotExist() assertion proves the password never leaks into the response.
- Save the test file.
- Stop the running application (Ctrl+C in the first terminal) and run the test by executing:
./mvnw test -Dtest=AuthRegistrationIntegrationTest
The first run may take 30-60 seconds as Docker pulls the postgres:17 image for the test container. Subsequent runs are faster because the image is cached.
You should see output ending with BUILD SUCCESS and Tests run: 1, Failures: 0, Errors: 0.
Test fails with a connection error?
Testcontainers requires Docker to be running. Confirm with docker info that your Docker daemon is responsive.
If you see a permissions error, ensure your user is in the docker group (set up in Step 1) and that you ran newgrp docker in this terminal session.
Help me fix my Testcontainers connection error
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files and your test passes with BUILD SUCCESS.
ⓧ I'd like to double check the full code
Your project should now have these files. Scroll up and confirm each one is present and matches:
- Role.java in the model package (USER and ADMIN enum values).
- User.java in the model package (JPA entity with id, email, password, role).
- UserRepository.java in the repository package (extends JpaRepository, has findByEmail).
- RegisterRequest.java in the dto package (email, password, role fields).
- UserResponse.java in the dto package (id and email only).
- AuthService.java in the service package (hashes password, returns UserResponse).
- AuthController.java in the controller package (POST /api/auth/register, returns 201).
- SecurityConfig.java in the config package (permits all, BCryptPasswordEncoder bean).
- AuthRegistrationIntegrationTest.java in src/test/java (Testcontainers + MockMvc).
You have your first working endpoint and an automated test to prove it. Next up, you will add input validation and clean error handling so the API rejects bad data gracefully instead of leaking stack traces.
Add Input Validation and Global Exception Handling
Your registration endpoint accepts any data right now. A user can register with an empty email, a two-character password, or duplicate credentials, and the API happily creates the record.
In this step, you will add Jakarta Bean Validation annotations to reject bad input at the door. You will also build a global exception handler that returns clean, consistent error responses instead of raw Spring stack traces.
In this step, get ready to:
- Add validation annotations that reject malformed registration requests.
- Create a global exception handler that returns structured error JSON.
- Write integration tests verifying all error scenarios.
Add validation annotations to the registration request
Jakarta Bean Validation lets you declare rules directly on DTO fields using annotations. Spring Boot checks these rules automatically when you mark a controller parameter with @Valid.
- Open RegisterRequest.java in your dto package.
- Replace the entire file contents with the following validated version:
package com.example.authapi.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public class RegisterRequest {
// Rejects empty strings and requires a valid email format
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
private String email;
// Requires at least 8 characters, one uppercase letter, one special character
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
@Pattern(
regexp = "^(?=.*[A-Z])(?=.*[!@#$%^&*]).*$",
message = "Password must contain an uppercase letter and a special character"
)
private String password;
private String role;
}
What do these annotations do?
- @NotBlank rejects null, empty strings, and whitespace-only strings.
- @Email checks that the value follows email format rules.
- @Size(min = 8) enforces a minimum character count.
- @Pattern validates the value against a regex. Here it requires at least one uppercase letter and one special character.
- Add the getter and setter methods for all three fields below the field declarations:
// Getters and setters
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
- Save RegisterRequest.java.
The annotations define the rules, but Spring will not enforce them until you tell the controller to validate the incoming object.
- Open AuthController.java.
- Find the method parameter for your register endpoint. It currently looks like this:
public ResponseEntity<UserResponse> register(@RequestBody RegisterRequest request) {
- Add @Valid before @RequestBody, so the line now looks like this:
public ResponseEntity<UserResponse> register(@Valid @RequestBody RegisterRequest request) {
Why @Valid here?
@Valid tells Spring to run the Bean Validation annotations on the deserialized request body before your method executes. If any constraint fails, Spring throws a MethodArgumentNotValidException and never calls your method.
- Add the jakarta.validation.Valid import at the top of the file:
import jakarta.validation.Valid;
- Save AuthController.java.
Before you test this, what do you think the response will look like when you send an invalid email? Will it list the specific field that failed?
- Restart your application by running:
./mvnw spring-boot:run
- Send an invalid registration request by running this curl command:
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"not-an-email","password":"ab","role":"USER"}'
You get a 400 Bad Request back. That is correct. But look at the response body. Spring returns a generic JSON with timestamp, status, error, and path fields. It does not tell the client which fields failed or why.
This is the gap. Validation works, but the error format is useless for any frontend or API consumer trying to display field-specific messages.
Still seeing 200 or no validation error?
- Confirm @Valid appears directly before @RequestBody in your controller method signature.
- Check that you imported jakarta.validation.Valid, not javax.validation.Valid. Spring Boot 3.x uses the Jakarta namespace.
- Make sure spring-boot-starter-validation is in your pom.xml dependencies.
Help me debug why my @Valid annotation is not triggering validation errors.
Create a global exception handler
The default error format proves the validation works, but it hides the details clients need. You will now create a @RestControllerAdvice class that intercepts exceptions and returns a structured response with field-level error messages.
- Create a new file ErrorResponse.java in your dto package.
- Paste the following class that defines the consistent error shape your API will always return:
package com.example.authapi.dto;
import java.time.LocalDateTime;
import java.util.List;
// Consistent error shape returned by all exception handlers
public class ErrorResponse {
private LocalDateTime timestamp;
private int status;
private String message;
private List<FieldError> errors;
public ErrorResponse(int status, String message, List<FieldError> errors) {
this.timestamp = LocalDateTime.now();
this.status = status;
this.message = message;
this.errors = errors;
}
// Getters
public LocalDateTime getTimestamp() { return timestamp; }
public int getStatus() { return status; }
public String getMessage() { return message; }
public List<FieldError> getErrors() { return errors; }
}
What does this class do?
Every error your API returns will use this shape: a timestamp for when it happened, a status code, a human-readable message, and an optional list of field-level errors. Clients can parse this predictably regardless of what went wrong.
- The ErrorResponse references a FieldError class. Create a new file FieldError.java in the same dto package with this content:
package com.example.authapi.dto;
// Represents a single field validation failure
public class FieldError {
private String field;
private String message;
public FieldError(String field, String message) {
this.field = field;
this.message = message;
}
public String getField() { return field; }
public String getMessage() { return message; }
}
- Save both files. Confirm ErrorResponse.java and FieldError.java appear in your dto package in the IntelliJ project explorer.
- Create a new exception package alongside your existing packages.
- Inside it, create ResourceAlreadyExistsException.java with this content:
package com.example.authapi.exception;
// Thrown when a registration or creation request conflicts with existing data
public class ResourceAlreadyExistsException extends RuntimeException {
public ResourceAlreadyExistsException(String message) {
super(message);
}
}
- Save the file. Confirm ResourceAlreadyExistsException.java appears in the exception package.
Now create the handler that catches exceptions and converts them into your ErrorResponse format.
- Create GlobalExceptionHandler.java in the exception package with the following code:
package com.example.authapi.exception;
import com.example.authapi.dto.ErrorResponse;
import com.example.authapi.dto.FieldError;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.List;
@RestControllerAdvice
public class GlobalExceptionHandler {
// Catches validation failures from @Valid
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationErrors(
MethodArgumentNotValidException ex) {
List<FieldError> fieldErrors = ex.getBindingResult()
.getFieldErrors().stream()
.map(err -> new FieldError(err.getField(), err.getDefaultMessage()))
.toList();
ErrorResponse response = new ErrorResponse(
400, "Validation failed", fieldErrors);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
}
What does @RestControllerAdvice do?
Spring scans for @RestControllerAdvice classes and routes unhandled exceptions from any controller to matching @ExceptionHandler methods. The handler converts the raw MethodArgumentNotValidException into your clean ErrorResponse format with per-field error messages.
- Save GlobalExceptionHandler.java and restart your application.
- Run the same invalid request again:
curl -s -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"not-an-email","password":"ab","role":"USER"}' | python3 -m json.tool
You should now see a structured 400 response with a timestamp, status of 400, message of "Validation failed", and an errors array listing each field that failed along with its specific violation message.
Still seeing the generic Spring error format?
- Confirm GlobalExceptionHandler has the @RestControllerAdvice annotation on the class.
- Confirm the class is in a package that Spring component-scans (a sub-package of your main application class).
- Ensure you fully restarted the application after saving the file.
Why is my GlobalExceptionHandler not intercepting validation errors?
The 400 validation path works. Now add handlers for duplicate resource conflicts.
- Open GlobalExceptionHandler.java and add these two handler methods below the existing handleValidationErrors method:
// Catches our custom exception for duplicate resources
@ExceptionHandler(ResourceAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleResourceExists(
ResourceAlreadyExistsException ex) {
ErrorResponse response = new ErrorResponse(
409, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
}
// Catches database constraint violations (e.g., unique email)
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleDataIntegrity(
DataIntegrityViolationException ex) {
ErrorResponse response = new ErrorResponse(
409, "Resource already exists", null);
return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
}
Why two handlers for conflicts?
ResourceAlreadyExistsException is thrown by your service when you explicitly check for duplicates. DataIntegrityViolationException is thrown by the database if a unique constraint is violated at the persistence layer. Both return 409, but catching both ensures no raw exception leaks to the client regardless of where the duplicate is caught.
- Now open AuthService.java and find the registerUser method.
- Add a duplicate email check at the top of the method, before the password hashing:
// Check if a user with this email already exists
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
throw new ResourceAlreadyExistsException(
"User with email " + request.getEmail() + " already exists");
}
- Add the import for ResourceAlreadyExistsException at the top of AuthService.java:
import com.example.authapi.exception.ResourceAlreadyExistsException;
- Save GlobalExceptionHandler.java and AuthService.java.
- Restart the application and test the duplicate email scenario. First, register a valid user:
curl -s -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"SecurePass1!","role":"USER"}' | python3 -m json.tool
- Now try to register the same email again:
curl -s -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"SecurePass1!","role":"USER"}' | python3 -m json.tool
You should see a 409 response with the message "User with email test@example.com already exists" and no errors array (since this is not a field validation issue).
Seeing a 500 instead of 409?
- Check that the ResourceAlreadyExistsException import in AuthService.java matches the package where you created the exception class.
- Verify the @ExceptionHandler(ResourceAlreadyExistsException.class) method is inside your GlobalExceptionHandler class.
- Confirm the exception class extends RuntimeException, not Exception.
Why am I seeing 500 Internal Server Error instead of 409 Conflict for duplicate emails?
Write integration tests for error responses
Your error handling works manually. Now lock it down with automated tests so future changes cannot break the error contract.
- Open your existing AuthRegistrationIntegrationTest.java test file.
- Add a test method that verifies a blank email returns 400 with the correct error structure. Place it below your existing registration success test:
@Test
void register_blankEmail_returns400WithFieldErrors() throws Exception {
// Send a request with an empty email field
String payload = "{\"email\":\"\",\"password\":\"SecurePass1!\",\"role\":\"USER\"}";
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.message").value("Validation failed"))
.andExpect(jsonPath("$.errors").isArray())
.andExpect(jsonPath("$.errors[0].field").value("email"));
}
What does this test prove?
The test sends a blank email, asserts the HTTP status is 400, and checks that the JSON body contains a status, message, and errors array with the field name email. This verifies both the validation annotation AND the global exception handler work together.
- Add a second test method for short passwords:
@Test
void register_shortPassword_returns400WithFieldErrors() throws Exception {
// Password too short - must be at least 8 characters
String payload = "{\"email\":\"valid@example.com\",\"password\":\"ab\",\"role\":\"USER\"}";
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.message").value("Validation failed"))
.andExpect(jsonPath("$.errors[?(@.field=='password')]").exists());
}
- Save and run these two tests to see them pass before adding the 409 test. In IntelliJ, right-click the test class and select Run.
Both 400 tests should pass with green checkmarks.
- Add the final test method for duplicate email detection:
@Test
void register_duplicateEmail_returns409() throws Exception {
// Register a user first
String payload = "{\"email\":\"dupe@example.com\",\"password\":\"SecurePass1!\",\"role\":\"USER\"}";
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isCreated());
// Attempt the same registration again
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(payload))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.status").value(409))
.andExpect(jsonPath("$.message").exists());
}
What does this test do?
It registers a user, then immediately tries the same email again. The first call should succeed with 201. The second call should fail with 409 and a structured error message. This proves the duplicate-check logic in your service works end-to-end.
Before you run the full test suite, do you expect all tests to pass on the first try?
- Run all tests in the class by right-clicking AuthRegistrationIntegrationTest and selecting Run.
All four tests should pass: the original 201 success test, both 400 validation tests, and the 409 duplicate test.
Tests failing with connection errors?
- The Testcontainers PostgreSQL container must be running during tests. Ensure Docker is running on your machine.
- If you see a timeout, Testcontainers might be pulling the PostgreSQL image for the first time. Wait for it to complete.
- Verify your test class has the @Testcontainers annotation and a @Container PostgreSQL field.
Help me fix Testcontainers connection failures in my Spring Boot integration tests.
✔️ Awesome, I've got everything!
Great. Double check you have saved all files and all four tests pass green.
ⓧ I'd like to double check the full code
Scroll up and confirm each of these files is present in your project, in order:
- RegisterRequest.java with @NotBlank, @Email, @Size, and @Pattern annotations.
- AuthController.java with @Valid before @RequestBody.
- ErrorResponse.java and FieldError.java in the dto package.
- ResourceAlreadyExistsException.java and GlobalExceptionHandler.java in the exception package.
- AuthService.java with the duplicate email check before password hashing.
- AuthRegistrationIntegrationTest.java with four test methods (201 success, 400 blank email, 400 short password, 409 duplicate).
Let's confirm everything works end-to-end. What do you expect to see if you send a request with an invalid email, a short password, and an invalid role all at once?
- Run this final verification:
curl -s -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"not-an-email","password":"ab","role":"INVALID"}' | python3 -m json.tool
You should see a 400 response with multiple entries in the errors array, one for email and at least one for password.
Your API now rejects bad input with clear messages and handles duplicates gracefully. Next up, you will add JWT authentication so users can prove their identity on every request.
Implement JWT Login
Users exist in your database, but there is no way to prove identity on subsequent requests. Right now, anyone who knows the endpoint can access it.
This step introduces stateless authentication. The client sends credentials once, receives a signed JWT, and includes it in every future request. The server never stores session state.
In this step, get ready to:
- Add the jjwt library and create a JwtService that generates and validates tokens.
- Wire an AuthenticationManager and build a login endpoint that returns a signed JWT.
- Write integration tests that verify the full login flow.
Add jjwt dependencies and configure the signing key
Before you can generate tokens, you need the jjwt library on the classpath and a secret key to sign them with.
- Open pom.xml in IntelliJ and add the following dependencies inside the <dependencies> block:
<!-- JWT support -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.13.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.13.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.13.0</version>
<scope>runtime</scope>
</dependency>
Why three separate artifacts?
jjwt splits its code into an API jar (compile-time) and implementation jars (runtime-only). Your application code only imports from jjwt-api. The runtime jars provide the actual signing and parsing logic without leaking internals into your compile classpath.
- Open src/main/resources/application.yml and add the JWT configuration below the existing server: block:
app:
jwt:
secret: dGhpcyBpcyBhIHNlY3VyZSBiYXNlNjQgZW5jb2RlZCBrZXkgZm9yIGRldmVsb3BtZW50IG9ubHk=
expiration: 86400000
What do these values mean?
The secret is a Base64-encoded string used as the HMAC signing key. In production you would use a cryptographically random key stored in a secrets manager.
The expiration is the token lifetime in milliseconds. 86400000 equals 24 hours.
- Verify the dependencies resolve by running:
./mvnw compile
- You should see BUILD SUCCESS in the output.
Seeing dependency resolution errors?
Make sure all three jjwt artifacts use the same version 0.13.0. A version mismatch between jjwt-api and jjwt-impl causes class-not-found errors at runtime.
help me fix jjwt dependency resolution errors in my Maven project
Create the JwtService class
The JwtService encapsulates all token operations: generating a signed token and extracting claims from an existing one.
- Create a new file src/main/java/.../service/JwtService.java and add the class structure with the token generation method:
package com.example.authapi.service;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.util.Base64;
import java.util.Date;
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String secret;
@Value("${app.jwt.expiration}")
private long expiration;
// Generates a signed JWT with email as subject and role as a claim
public String generateToken(UserDetails userDetails, String role) {
return Jwts.builder()
.subject(userDetails.getUsername())
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey())
.compact();
}
What does this code do?
The generateToken method builds a JWT with the user's email as the sub claim, their role as a custom claim, timestamps for issued-at and expiration, and signs everything with your secret key.
The @Value annotations pull the secret and expiration from application.yml so you never hardcode credentials.
- Below the generateToken method, add the parsing and extraction utilities:
// Decodes the Base64 secret into a signing key
private SecretKey getSigningKey() {
byte[] keyBytes = Base64.getDecoder().decode(secret);
return Keys.hmacShaKeyFor(keyBytes);
}
// Parses and verifies the token, returning all claims
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
public String extractUsername(String token) {
return extractAllClaims(token).getSubject();
}
public String extractRole(String token) {
return extractAllClaims(token).get("role", String.class);
}
What does this code do?
The getSigningKey method decodes the Base64 secret from your config into a cryptographic key. The extractAllClaims method parses and verifies the token signature in one step. If the signature is invalid or the token is malformed, jjwt throws an exception automatically.
- Finally, add the validation methods and close the class:
// Checks the token is valid and belongs to this user
public boolean validateToken(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername())
&& !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractAllClaims(token).getExpiration().before(new Date());
}
}
What does validateToken do?
It checks both that the username in the token matches the loaded user and that the token has not expired. This method is used later in the JWT filter to gate access to protected endpoints.
- Save JwtService.java and confirm it compiles by running:
./mvnw compile
- You should see BUILD SUCCESS again, confirming the jjwt imports resolve correctly.
Build the login endpoint
With the JwtService ready, you need a way for users to exchange credentials for a token. This requires a CustomUserDetailsService that loads users from the database, an AuthenticationManager to verify passwords, and the endpoint itself.
- Create src/main/java/.../service/CustomUserDetailsService.java and add the class structure:
package com.example.authapi.service;
import com.example.authapi.model.User;
import com.example.authapi.repository.UserRepository;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
- Below the constructor, add the loadUserByUsername method that bridges your User entity with Spring Security:
// Loads a user by email and converts to Spring Security UserDetails
@Override
public UserDetails loadUserByUsername(String email)
throws UsernameNotFoundException {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException(
"User not found: " + email));
return new org.springframework.security.core.userdetails.User(
user.getEmail(), user.getPassword(),
List.of(new SimpleGrantedAuthority(
"ROLE_" + user.getRole().name())));
}
}
Why a custom UserDetailsService?
Spring Security's AuthenticationManager needs a UserDetailsService to look up users during authentication. Your users live in PostgreSQL, so this class loads them by email and converts the User entity into Spring Security's UserDetails format, including the role as a granted authority.
- Create src/main/java/.../dto/LoginRequest.java for the login request body:
package com.example.authapi.dto;
// Carries the login credentials from the client
public class LoginRequest {
private String email;
private String password;
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
}
- Create src/main/java/.../dto/LoginResponse.java for the token response:
package com.example.authapi.dto;
// Wraps the signed JWT returned to the client
public class LoginResponse {
private String accessToken;
public LoginResponse(String accessToken) {
this.accessToken = accessToken;
}
public String getAccessToken() { return accessToken; }
public void setAccessToken(String accessToken) {
this.accessToken = accessToken;
}
}
- Verify everything compiles so far by running:
./mvnw compile
- You should see BUILD SUCCESS.
- Now update SecurityConfig.java to expose the AuthenticationManager and PasswordEncoder beans. Replace the entire file contents with the following. First, add the imports and filterChain:
package com.example.authapi.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
return http.build();
}
- Directly below the filterChain method, add the two new beans and close the class:
// Exposes the AuthenticationManager for use in the login flow
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
// BCrypt encoder for hashing and verifying passwords
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
What changed here?
You added two new beans. The AuthenticationManager bean delegates to Spring's AuthenticationConfiguration which automatically wires your CustomUserDetailsService and PasswordEncoder together.
The PasswordEncoder bean is now centralized here so both registration and authentication use the same BCrypt encoder.
- Open src/main/java/.../service/AuthService.java and add the following imports at the top of the file:
import com.example.authapi.dto.LoginRequest;
import com.example.authapi.dto.LoginResponse;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
- Now find your existing constructor and fields in AuthService.java. Replace them with the updated version that includes the new dependencies, and add the loginUser method below the existing registerUser method:
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
// Updated constructor with all dependencies
public AuthService(UserRepository userRepository,
PasswordEncoder passwordEncoder,
AuthenticationManager authenticationManager,
JwtService jwtService) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.authenticationManager = authenticationManager;
this.jwtService = jwtService;
}
// Authenticates credentials and returns a signed JWT
public LoginResponse loginUser(LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(), request.getPassword()));
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String role = userDetails.getAuthorities().iterator().next()
.getAuthority().replace("ROLE_", "");
String token = jwtService.generateToken(userDetails, role);
return new LoginResponse(token);
}
What does loginUser do?
It delegates credential verification to the AuthenticationManager, which internally calls your CustomUserDetailsService to load the user and BCrypt to check the password. If authentication succeeds, it extracts the role from the granted authorities and passes everything to JwtService to mint a token.
- Add the login endpoint to AuthController.java. First add these imports at the top of the file:
import com.example.authapi.dto.LoginRequest;
import com.example.authapi.dto.LoginResponse;
- Below your existing register method, add the login endpoint:
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(
@RequestBody LoginRequest request) {
LoginResponse response = authService.loginUser(request);
return ResponseEntity.ok(response);
}
- Save all files.
Before you test, think: what do you expect to happen if you send valid credentials to /api/auth/login?
- Start the application by running:
./mvnw spring-boot:run
- Register a test user (if you do not still have one from the previous step):
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"SecurePass1!","role":"USER"}'
- Now test the login endpoint:
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"SecurePass1!"}'
You should see a 200 response with a JSON body containing an accessToken field. The token is a long Base64-encoded string with three dot-separated segments.
- Copy the token value and paste it into jwt.io to inspect the claims. You should see your email as the sub claim and USER as the role claim.
- Now test with wrong credentials:
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"WrongPassword"}'
You should receive a 401 Unauthorized response.
Getting a 403 instead of 401?
Make sure your SecurityFilterChain still permits all requests. If you accidentally added .anyRequest().authenticated() instead of .anyRequest().permitAll(), Spring will block the login request before it reaches your controller.
help me debug why my login endpoint returns 403 Forbidden instead of processing the request
- Stop the application with Ctrl+C before proceeding to the tests.
Write integration tests for login
Automated tests catch regressions before they reach production. You will test three scenarios: valid login, wrong password, and non-existent user.
- Create src/test/java/.../AuthLoginIntegrationTest.java with the imports:
package com.example.authapi;
import com.example.authapi.dto.LoginRequest;
import com.example.authapi.dto.RegisterRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
- Below the imports, add the class declaration, container setup, and @BeforeEach method:
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class AuthLoginIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:17");
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@BeforeEach
void registerTestUser() throws Exception {
RegisterRequest reg = new RegisterRequest();
reg.setEmail("login@test.com");
reg.setPassword("SecurePass1!");
reg.setRole("USER");
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(reg)));
}
What does the setup do?
The @Container annotation spins up a real PostgreSQL instance in Docker for each test class. @DynamicPropertySource overrides your application.yml database settings so tests hit the disposable container.
The @BeforeEach method ensures a known user exists before each test runs.
- Add the first test method that verifies a successful login:
@Test
void loginWithValidCredentials_returns200WithToken() throws Exception {
LoginRequest login = new LoginRequest();
login.setEmail("login@test.com");
login.setPassword("SecurePass1!");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(login)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.accessToken").isNotEmpty());
}
- Below that test, add the two failure-case tests and close the class:
@Test
void loginWithWrongPassword_returns401() throws Exception {
LoginRequest login = new LoginRequest();
login.setEmail("login@test.com");
login.setPassword("WrongPassword");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(login)))
.andExpect(status().isUnauthorized());
}
@Test
void loginWithNonExistentUser_returns401() throws Exception {
LoginRequest login = new LoginRequest();
login.setEmail("nobody@test.com");
login.setPassword("SecurePass1!");
mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(login)))
.andExpect(status().isUnauthorized());
}
}
What do these tests verify?
The first test confirms that valid credentials return 200 with a non-empty accessToken field. The second and third tests verify that bad passwords and non-existent emails both return 401 Unauthorized.
- Save the test file and run the full test suite:
./mvnw test
Before you check the output, predict: do you expect all tests to pass on the first run?
You should see all tests pass, including the new login tests and the existing registration tests. If you see BUILD SUCCESS and a test count that includes your three new tests, the login flow is working end-to-end.
Tests failing with authentication errors?
If you see AuthenticationException in the test output, verify that your GlobalExceptionHandler catches AuthenticationException and returns 401. Spring Security throws BadCredentialsException for wrong passwords, and your handler needs to convert it to a 401 response.
help me fix authentication test failures in my Spring Boot JWT project
✔️ Awesome, I've got everything!
Great. Make sure you have saved all files and that ./mvnw test shows all tests passing.
ⓧ I'd like to double check the full code
Since your project now spans many files, confirm each of the following files is present and saved with the code from the chunks above:
- pom.xml with three jjwt 0.13.0 dependencies added.
- application.yml with the app.jwt.secret and app.jwt.expiration keys.
- JwtService.java with generateToken, extractUsername, extractRole, and validateToken methods.
- CustomUserDetailsService.java implementing UserDetailsService.
- LoginRequest.java and LoginResponse.java DTOs.
- SecurityConfig.java with filterChain, authenticationManager, and passwordEncoder beans.
- AuthService.java with loginUser method and updated constructor.
- AuthController.java with the /login endpoint.
- AuthLoginIntegrationTest.java with three test methods.
Your API now issues signed JWTs on successful login. Next up, you will create a filter that reads these tokens from incoming requests and enforces role-based access control on protected endpoints.
Add the JWT Filter and Role-Based Access Control
In the previous step, you built the login endpoint. Users can now send credentials and receive a signed JWT. That token proves who they are.
The problem is that the token is just decoration right now. Your API does not check it on subsequent requests. Any request to any endpoint goes through without authentication. This step closes the security loop by adding a filter that validates tokens on every request and restricting endpoints based on the user's role.
In this step, get ready to:
- Create a JWT authentication filter that intercepts every request and validates the Bearer token.
- Lock down the security chain with role-based access rules and add protected endpoints.
- Write integration tests proving that USER, ADMIN, and unauthenticated requests get the correct responses.
Create the JWT authentication filter
Every request to a protected endpoint must carry a valid JWT in the Authorization header. You need a filter that runs before Spring Security's default authentication, extracts the token, validates it, and tells Spring who the user is.
- Create a new file JwtAuthenticationFilter.java inside your security package (create the package if it does not exist).
- Add the class skeleton with its dependencies by pasting this code:
package com.example.authapi.security;
import com.example.authapi.service.CustomUserDetailsService;
import com.example.authapi.service.JwtService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final CustomUserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtService jwtService,
CustomUserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
}
What does this code do?
The @Component annotation registers this filter as a Spring bean so you can inject it into your security configuration. It extends OncePerRequestFilter, which guarantees your logic runs exactly once per HTTP request.
The constructor injects JwtService (to parse and validate tokens) and CustomUserDetailsService (to load the user from the database by email).
- Now add the doFilterInternal method inside the class body. This is the logic that runs on every request:
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// Extract the Authorization header
String authHeader = request.getHeader("Authorization");
// Skip if no Bearer token present
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// Parse token and extract username (email)
String token = authHeader.substring(7);
String username = jwtService.extractUsername(token);
// Validate and set authentication context
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.validateToken(token, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
What does this filter do on each request?
- It reads the Authorization header. If missing or not prefixed with Bearer , the filter passes the request along untouched.
- It strips the Bearer prefix to get the raw JWT, then calls extractUsername to read the subject claim.
- If the token is valid and no authentication exists for this request yet, it loads the user from the database and creates a UsernamePasswordAuthenticationToken with the user's authorities.
- It places that token in SecurityContextHolder, which tells Spring Security who the current user is for the remainder of the request.
- Save JwtAuthenticationFilter.java.
- Restart the application with ./mvnw spring-boot:run to confirm it compiles without errors.
Seeing a compilation error?
- Check that your JwtService class has a public extractUsername(String token) method and a public validateToken(String token, UserDetails userDetails) method.
- Verify the import for jakarta.servlet (not javax.servlet). Spring Boot 3.x uses Jakarta EE.
Help me fix my JwtAuthenticationFilter compilation error.
Lock down the security chain and add protected endpoints
The filter exists, but your SecurityConfig still permits all requests. You need to replace the temporary open policy with proper access rules and register your filter in the chain.
- Open SecurityConfig.java and replace its entire contents with the following:
package com.example.authapi.config;
import com.example.authapi.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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
@EnableWebSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// Disable CSRF (stateless API does not need it)
http.csrf(csrf -> csrf.disable())
// No server-side sessions
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// URL-based access rules
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
// Run JWT filter before default auth filter
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
What changed in the security chain?
- CSRF is disabled because your API is stateless. Clients send a JWT, not a cookie.
- Session creation is set to STATELESS so Spring never stores user state on the server.
- Requests to /api/auth/** are open (register and login). Requests to /api/admin/** require ROLE_ADMIN. Everything else requires authentication.
- Your JwtAuthenticationFilter is registered before UsernamePasswordAuthenticationFilter, so it runs first and populates the security context.
- Save SecurityConfig.java and restart the application.
Before you test, what do you think will happen if you send a request to /api/auth/register without a token? What about a random path like /api/users/me?
- Test that unauthenticated requests are now blocked by running:
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/users/me
You should see 401 (or 403). The security chain is now enforcing authentication. Registration still works because /api/auth/** is permitted.
Now you need actual endpoints behind those access rules. The /api/users/me endpoint returns the authenticated user's own profile. The /api/admin/users endpoint returns all users and is restricted to admins.
- Create a new file UserController.java in your controller package:
package com.example.authapi.controller;
import com.example.authapi.dto.UserResponse;
import com.example.authapi.model.User;
import com.example.authapi.repository.UserRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserRepository userRepository;
public UserController(UserRepository userRepository) {
this.userRepository = userRepository;
}
// Returns the authenticated user's own profile
@GetMapping("/me")
public ResponseEntity<UserResponse> getCurrentUser(Authentication authentication) {
String email = authentication.getName();
User user = userRepository.findByEmail(email).orElseThrow();
return ResponseEntity.ok(new UserResponse(user.getId(), user.getEmail()));
}
}
How does it know who the user is?
Spring injects the Authentication object automatically because your JwtAuthenticationFilter already placed it in SecurityContextHolder. Calling authentication.getName() returns the email (the JWT subject claim).
- Create a new file AdminController.java in your controller package:
package com.example.authapi.controller;
import com.example.authapi.dto.UserResponse;
import com.example.authapi.repository.UserRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/admin")
public class AdminController {
private final UserRepository userRepository;
public AdminController(UserRepository userRepository) {
this.userRepository = userRepository;
}
// Returns all users (ADMIN access only)
@GetMapping("/users")
public ResponseEntity<List<UserResponse>> getAllUsers() {
List<UserResponse> users = userRepository.findAll().stream()
.map(user -> new UserResponse(user.getId(), user.getEmail()))
.toList();
return ResponseEntity.ok(users);
}
}
Why no role check inside the controller?
The SecurityFilterChain already declares that /api/admin/** requires ROLE_ADMIN. Spring rejects non-admin users before the request ever reaches this controller. The controller only contains the business logic.
- Save both files and restart the application.
Time to test the full RBAC flow. First, register a USER and an ADMIN, then log in to get tokens.
- Register a test user by running:
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"user@test.com","password":"SecurePass1!","role":"USER"}'
- Register an admin user by running:
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"admin@test.com","password":"SecurePass1!","role":"ADMIN"}'
- Log in as the USER to get a token:
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@test.com","password":"SecurePass1!"}'
Copy the accessToken value from the JSON response. You will use it in the next commands.
Before you run the next command, predict what will happen. You are sending a USER token to a user-accessible endpoint.
- Call GET /api/users/me with your USER token:
curl http://localhost:8080/api/users/me \
-H "Authorization: Bearer your-user-token-here"
You should see a 200 response with your user's ID and email.
- Now try accessing the admin endpoint with the same USER token:
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/admin/users \
-H "Authorization: Bearer your-user-token-here"
You should see 403 Forbidden. The USER role does not have permission to access admin endpoints.
- Log in as the admin and call the same endpoint with the ADMIN token:
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@test.com","password":"SecurePass1!"}'
curl http://localhost:8080/api/admin/users \
-H "Authorization: Bearer your-admin-token-here"
You should see a 200 response listing all registered users. Role-based access control is working.
Getting 403 on every endpoint?
- Check that your CustomUserDetailsService returns authorities with the ROLE_ prefix (e.g. ROLE_USER, ROLE_ADMIN). Spring's hasRole("ADMIN") automatically prepends ROLE_ when comparing.
- Verify that the token you are pasting is the full string from the accessToken field, with no trailing whitespace.
Help me debug why my JWT token always returns 403 Forbidden.
Write integration tests for the RBAC flow
Manual curl testing confirms the behavior, but you need automated tests that verify the full RBAC flow. These tests use Testcontainers to spin up a real PostgreSQL instance and exercise the filter chain end-to-end.
- Create a new test file RbacIntegrationTest.java in your test directory. Start with the imports:
package com.example.authapi;
import com.example.authapi.dto.LoginRequest;
import com.example.authapi.dto.RegisterRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
- Add the class declaration, Testcontainers setup, and helper method:
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class RbacIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17");
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
// Helper: register a user and return login token
private String registerAndLogin(String email, String password, String role)
throws Exception {
RegisterRequest reg = new RegisterRequest(email, password, role);
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(reg)));
LoginRequest login = new LoginRequest(email, password);
MvcResult result = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(login)))
.andReturn();
return objectMapper.readTree(
result.getResponse().getContentAsString()).get("accessToken").asText();
}
}
What does the helper do?
The registerAndLogin method registers a user, logs them in, and extracts the accessToken from the response. Each test calls this helper to get a ready-to-use token without repeating boilerplate.
- Add the test methods inside the class. First, the success cases:
@Test
void userCanAccessOwnProfile() throws Exception {
String token = registerAndLogin("rbac-user@test.com", "SecurePass1!", "USER");
mockMvc.perform(get("/api/users/me")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value("rbac-user@test.com"));
}
@Test
void adminCanAccessAdminEndpoint() throws Exception {
String token = registerAndLogin("rbac-admin@test.com", "SecurePass1!", "ADMIN");
mockMvc.perform(get("/api/admin/users")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
}
- Add the denial and rejection tests:
@Test
void userCannotAccessAdminEndpoint() throws Exception {
String token = registerAndLogin("rbac-nonadmin@test.com", "SecurePass1!", "USER");
mockMvc.perform(get("/api/admin/users")
.header("Authorization", "Bearer " + token))
.andExpect(status().isForbidden());
}
@Test
void noTokenReturnsUnauthorized() throws Exception {
mockMvc.perform(get("/api/users/me"))
.andExpect(status().isUnauthorized());
}
@Test
void expiredTokenReturnsUnauthorized() throws Exception {
// Use a token that was generated with a past expiration
String expiredToken = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJleHBpcmVkQHRlc3QuY29tIiwiaWF0IjoxNjAwMDAwMDAwLCJleHAiOjE2MDAwMDAwMDF9.invalid";
mockMvc.perform(get("/api/users/me")
.header("Authorization", "Bearer " + expiredToken))
.andExpect(status().isUnauthorized());
}
What do these tests prove?
- A USER with a valid token can access /api/users/me (200).
- An ADMIN can access /api/admin/users (200).
- A USER cannot access admin-only endpoints (403).
- A request with no token is rejected (401).
- An expired or invalid token is rejected (401).
- Save RbacIntegrationTest.java.
- Stop the running application (Ctrl+C), then run the tests:
./mvnw test -Dtest=RbacIntegrationTest
What do you expect to see? All five tests should pass. Testcontainers will pull the postgres:17 image (already cached from earlier) and run the full Spring Boot context against it.
You should see output ending with Tests run: 5, Failures: 0, Errors: 0.
Tests failing with connection errors?
- Make sure Docker is running. Testcontainers needs the Docker daemon to start the PostgreSQL container.
- If you see Could not find a valid Docker environment, check that your user is in the docker group by running groups in a terminal.
Help me fix my Testcontainers tests that cannot connect to Docker.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three new files: JwtAuthenticationFilter.java, UserController.java, and AdminController.java.
ⓧ I'd like to double check the full code
Scroll up and confirm each file you created matches the code blocks above, in this order:
- JwtAuthenticationFilter.java (class skeleton chunk + doFilterInternal chunk).
- SecurityConfig.java (imports chunk + class body chunk + beans chunk).
- UserController.java (full file).
- AdminController.java (full file).
- RbacIntegrationTest.java (imports chunk + class setup chunk + helper chunk + success tests chunk + denial tests chunk).
Your API is now fully secured. Every request is validated by the JWT filter, and endpoints enforce role-based access. Next up, you will take on the Secret Mission and harden the API with OWASP best practices.
Secret mission
Add OWASP Security Hardening with Rate Limiting
Your API authenticates users and enforces roles, but it is wide open to brute-force attacks. An attacker can try thousands of passwords against the login endpoint with no penalty. In this mission, you will add defence in depth: rate limiting on the login endpoint, OWASP security headers, and cache-control hardening.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with Docker, so there are no ongoing cloud costs.
Resources you used:
- Docker container auth-db running PostgreSQL 17 (no cost, runs locally).
- Spring Boot project folder on your machine.
Keep everything running
No action needed. Choose this if you're still actively building on the API or plan to extend it soon.
- The auth-db container uses minimal resources while running. It restarts automatically if Docker is running.
- Your project folder stays on disk for future development.
Pause - I'll come back to this later
Shut down running processes to free up system resources. Your data and files stay intact for when you return.
- Stop the Spring Boot application by pressing Ctrl+C in the terminal where it is running.
- Stop the PostgreSQL container by running:
docker stop auth-db
- When you are ready to resume, start it again by running:
docker start auth-db
- Your database data persists between stops and starts.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes your database data.
- Remove the Docker container (this also stops it if running) by running:
docker rm -f auth-db
- All database data is lost. You can recreate it later by running the original docker run command from Step 1.
- Delete your Spring Boot project folder by running:
rm -rf ~/IdeaProjects/your-project-folder
Find your project path
In IntelliJ IDEA, right-click the project root in the Project panel and select Copy Path/Reference to get the exact folder location on your machine.
Nice Work!
Nice Work!
You just built a production-style REST API with stateless JWT authentication, role-based access control, and clean error handling from scratch. This is the backend security skeleton that powers real-world services.
You've learned how to:
- Build a Spring Boot REST API with JWT-based stateless authentication backed by PostgreSQL and BCrypt password hashing.
- Implement role-based access control using a custom Spring Security filter chain that restricts endpoints based on USER and ADMIN roles.
- Create consistent error handling with input validation, global exception handling, and end-to-end integration tests using Testcontainers.
- Secret Mission: Hardened the API with OWASP security headers and rate limiting on the login endpoint to defend against brute-force attacks.
Ready to quiz yourself?