JWT Authentication API with Spring Boot
Build a secure REST API with JWT auth, BCrypt hashing, and MySQL.
Introduction
30 Second Summary
Every time you log into an app, something behind the scenes proves you are who you say you are. Most modern APIs do this with a compact, self-contained token that travels with every request.
In this project, you will build a Task Manager REST API that issues and validates its own JWT tokens. You will wire up user registration, login, and protected endpoints from scratch using Spring Boot and MySQL.
What You'll Build
A stateless REST API where users register, log in to receive a signed JWT, then use that token to create, read, update, and delete their own tasks.
By the end of this project, you'll have:
- A running Spring Boot 3.5 API with Spring Security 6, Spring Data JPA, and MySQL handling user registration and login.
- A custom JWT authentication filter that validates tokens on every request and populates the security context.
- Protected Task CRUD endpoints with per-user data isolation, so users can only access their own tasks.
- Secret Mission: Add role-based access control (ADMIN vs USER) with different endpoint permissions.
Are there any prerequisites?
This project assumes you have Java web development experience. You will need JDK 17, Maven, and MySQL running locally, plus a code editor like VS Code or Eclipse.
Set Up the Spring Boot Project
Before you can build a JWT authentication system, you need a properly configured Spring Boot project with the right dependencies. This project needs Spring Security for the filter chain, Spring Data JPA for database access, and a JWT library for token creation and validation.
In this step, you will generate a project scaffold from Spring Initializr, add the JJWT token library, and connect the project to a MySQL database.
In this step, get ready to:
- Generate a Spring Boot project with core dependencies from Spring Initializr.
- Add the JJWT token library to your project.
- Create a MySQL database and configure the application to connect to it.
Generate the project from Spring Initializr
Spring Initializr is a web tool that generates a ready-to-run Spring Boot project with your chosen dependencies pre-configured in the build file.
- Open your browser and navigate to https://start.spring.io.
- Under Project, select Maven.
- Under Language, select Java.
- Under Spring Boot, select 3.5.14.
- In the Project Metadata section, set Group to com.example and Artifact to taskmanager.
- Set Packaging to Jar and Java to 17.
- Click ADD DEPENDENCIES and search for each of the following, adding them one at a time: Spring Web, Spring Security, Spring Data JPA, MySQL Driver.
- Click GENERATE to download the project as a ZIP file.
- Extract the ZIP file to your Desktop.
What did Spring Initializr create?
The generated ZIP contains a complete Maven project structure. The pom.xml file lists your chosen dependencies. The TaskmanagerApplication.java file is the entry point for the app.
A Maven wrapper (mvnw on macOS/Linux, mvnw.cmd on Windows) is also included so you can run Maven commands without installing Maven globally.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the extracted taskmanager folder, then click Open.
Using Eclipse instead?
If you prefer Eclipse, open it and go to File > Import > Existing Maven Projects. Browse to the extracted taskmanager folder and click Finish.
Add the JJWT dependencies to your project
Spring Initializr does not include JJWT as an option. You need to add this library manually so your project can create and validate JWT tokens.
- In VS Code, open pom.xml from the file explorer sidebar.
- Find the <dependencies> section. Locate the closing </dependency> tag of the last dependency (the test starter).
- Add the following three JJWT dependencies directly above the closing </dependencies> tag:
<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>
What are these three JJWT dependencies?
JJWT splits its library into three modules. jjwt-api provides the interfaces your code compiles against. jjwt-impl contains the actual implementation logic. jjwt-jackson handles JSON serialization for the token payload.
The runtime scope means those JARs are only needed when the app runs, not when it compiles. This keeps your compile-time classpath clean.
- Save pom.xml.
- Open a terminal in VS Code by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
- Verify all dependencies resolve by running this command:
macOS
./mvnw compile
Windows
mvnw.cmd compile
- You should see BUILD SUCCESS in the terminal output.
Seeing BUILD FAILURE?
If Maven cannot resolve the JJWT dependencies, double-check that the version is exactly 0.13.0 in all three entries. Also confirm that each <dependency> block is properly closed with </dependency>.
On macOS/Linux, if you see a "Permission denied" error, run chmod +x mvnw first to make the wrapper executable.
help me fix a Maven build failure when adding JJWT dependencies
✔️ Awesome, I've got everything!
Great. Make sure you have saved pom.xml before moving on.
ⓧ I'd like to double check the full code
Here is what your complete pom.xml should look like at this point:
<?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.5.14</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>taskmanager</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>taskmanager</name>
<description>JWT Task Manager API</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Create the MySQL database and configure the application
Spring Boot needs a running database to connect to. You will create the database in MySQL and then tell your application where to find it via configuration properties.
- Open a new terminal window (separate from your VS Code terminal) and connect to MySQL by running:
mysql -u root -p
- Enter your MySQL root password when prompted.
- Create the project database by running:
CREATE DATABASE taskmanager_db;
- Verify the database was created by running:
SHOW DATABASES;
- You should see taskmanager_db in the list. Type exit to leave the MySQL shell.
Why a separate database?
Each application should have its own database to keep data isolated. The taskmanager_db database will hold the user and task tables that Spring Data JPA creates automatically for you.
Now configure your application to connect to this database and set up JWT properties.
- In VS Code, open src/main/resources/application.properties.
- Replace the empty file contents with the following configuration:
spring.datasource.url=jdbc:mysql://localhost:3306/taskmanager_db
spring.datasource.username=root
spring.datasource.password=your-mysql-password-here
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
app.jwt.secret=my-super-secret-key-that-is-at-least-256-bits-long-for-hs256-signing
app.jwt.expiration-ms=86400000
What do these properties control?
- spring.datasource.* tells Spring how to connect to your MySQL database.
- spring.jpa.hibernate.ddl-auto=update automatically creates or updates database tables to match your Java entities.
- spring.jpa.show-sql=true prints SQL queries to the console so you can see what Hibernate is doing.
- app.jwt.secret is the signing key for your JWTs. It must be at least 32 characters (256 bits) for HMAC-SHA256.
- app.jwt.expiration-ms sets token lifetime to 86400000 milliseconds (24 hours).
- Replace your-mysql-password-here with your actual MySQL root password.
- Save application.properties.
Keep your password safe
In a real project, you would store database credentials in environment variables or a secrets manager. For this learning project, hardcoding the password in application.properties is acceptable.
Now confirm that everything is wired correctly by starting the application.
- In your VS Code terminal, start the application by running:
macOS
./mvnw spring-boot:run
Windows
mvnw.cmd spring-boot:run
- Watch the console output. You should see a line that reads Started TaskmanagerApplication near the end.
- Open a new terminal tab and test the default security by running:
curl http://localhost:8080
- You should receive a 401 Unauthorized response. This confirms Spring Security is active and blocking all requests by default.
Why am I getting 401 already?
When spring-boot-starter-security is on the classpath without any custom configuration, Spring Security locks down every endpoint. It generates a random password printed in the console output. You will replace this default behavior with your own JWT-based security in Step 3.
Application fails to start?
If you see a "Communications link failure" or "Access denied" error, confirm your MySQL server is running and that the password in application.properties matches your MySQL root password exactly.
If you see "Unknown database 'taskmanager_db'", go back to your MySQL shell and run the CREATE DATABASE command again.
help me fix a Spring Boot startup error connecting to MySQL
✔️ Awesome, I've got everything!
Great. Make sure you have saved application.properties before moving on.
ⓧ I'd like to double check the full code
Here is what your complete application.properties should look like:
spring.datasource.url=jdbc:mysql://localhost:3306/taskmanager_db
spring.datasource.username=root
spring.datasource.password=your-mysql-password-here
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
app.jwt.secret=my-super-secret-key-that-is-at-least-256-bits-long-for-hs256-signing
app.jwt.expiration-ms=86400000
Your project skeleton is ready with all the dependencies wired up. Next, you will create the data model that defines how users and tasks are stored in the database.
Create the Data Model and Repositories
Your Spring Boot project is fully configured and running on port 8080. The next goal is to define what data your API stores and how it connects to the database.
Spring Data JPA lets you map Java classes directly to MySQL tables using annotations. You define the shape of your data once in Java, and Hibernate handles creating the tables and running queries for you.
In this step, get ready to:
- Create User and Task JPA entities that map to MySQL tables.
- Build repository interfaces for database queries.
- Wire a UserDetails adapter to connect your User model to Spring Security.
Create the User entity
Every user who registers will have a username, email, password, and role stored in the database. The User entity defines this structure as a Java class that Hibernate maps to a users table.
- Create a new folder structure inside your project: src/main/java/com/example/taskmanager/model.
- Create a file called User.java inside the model folder, then paste the following code:
package com.example.taskmanager.model;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
// Primary key, auto-incremented by the database
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Each username must be unique across all users
@Column(unique = true, nullable = false)
private String username;
// Each email must be unique across all users
@Column(unique = true, nullable = false)
private String email;
// Stores the hashed password (never plain text)
@Column(nullable = false)
private String password;
// Role for authorization: "USER" or "ADMIN"
@Column(nullable = false)
private String role = "USER";
public User() {}
public User(String username, String email, String password) {
this.username = username;
this.email = email;
this.password = password;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
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; }
}
What does this code do?
The @Entity annotation tells Hibernate this class maps to a database table. @Table(name = "users") sets the table name explicitly.
Each field annotated with @Column becomes a column in the table. The unique = true constraint ensures no two users can share the same username or email. The role field defaults to "USER" so every new registration gets standard permissions.
- Save User.java.
Create the Task entity
Each task belongs to a specific user. The Task entity defines the task fields and a relationship back to the user who owns it.
- Create a file called Task.java inside the same model folder, then paste the following code:
package com.example.taskmanager.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "tasks")
public class Task {
// Primary key, auto-incremented by the database
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// The task title is required
@Column(nullable = false)
private String title;
// Optional longer description of the task
private String description;
// Tracks whether the task is done
private boolean completed = false;
// Automatically set to the current time when created
private LocalDateTime createdAt = LocalDateTime.now();
// Each task belongs to one user (many tasks per user)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
public Task() {}
public Task(String title, String description, User user) {
this.title = title;
this.description = description;
this.user = user;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public boolean isCompleted() { return completed; }
public void setCompleted(boolean completed) { this.completed = completed; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public User getUser() { return user; }
public void setUser(User user) { this.user = user; }
}
What does @ManyToOne mean?
The @ManyToOne annotation creates a foreign key relationship. Many tasks can belong to one user. Hibernate adds a user_id column to the tasks table that points to the users table.
The FetchType.LAZY setting means the User object is only loaded from the database when you actually access it. This improves performance by avoiding unnecessary queries.
- Save Task.java.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files in the model folder.
ⓧ I'd like to double check the full code
Here is the complete User.java:
package com.example.taskmanager.model;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String username;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String role = "USER";
public User() {}
public User(String username, String email, String password) {
this.username = username;
this.email = email;
this.password = password;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
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; }
}
Here is the complete Task.java:
package com.example.taskmanager.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "tasks")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
private String description;
private boolean completed = false;
private LocalDateTime createdAt = LocalDateTime.now();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
public Task() {}
public Task(String title, String description, User user) {
this.title = title;
this.description = description;
this.user = user;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public boolean isCompleted() { return completed; }
public void setCompleted(boolean completed) { this.completed = completed; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public User getUser() { return user; }
public void setUser(User user) { this.user = user; }
}
Create the repository interfaces
Repositories are interfaces that let you query the database without writing SQL. Spring Data JPA generates the implementation automatically based on method names.
- Create a new folder: src/main/java/com/example/taskmanager/repository.
- Create a file called UserRepository.java inside the repository folder, then paste the following code:
package com.example.taskmanager.repository;
import com.example.taskmanager.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
// Provides CRUD operations plus custom query methods for User
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
}
- Create a file called TaskRepository.java in the same repository folder, then paste the following code:
package com.example.taskmanager.repository;
import com.example.taskmanager.model.Task;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
// Provides CRUD operations plus a query to find tasks by owner
public interface TaskRepository extends JpaRepository<Task, Long> {
List<Task> findByUserId(Long userId);
}
How do these interfaces work without code?
Spring Data JPA reads the method names and generates SQL queries at runtime. For example, findByUsername(String username) becomes SELECT * FROM users WHERE username = ?. You get full database access without writing a single query.
The JpaRepository<User, Long> base interface provides built-in methods like save(), findById(), findAll(), and delete() for free.
- Save both UserRepository.java and TaskRepository.java.
Seeing import errors?
Make sure the package declarations match the folder structure exactly. The User import in UserRepository.java must be com.example.taskmanager.model.User. If your IDE shows red underlines, check that the model folder is in the correct location.
Help me fix import errors in my repository interfaces.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved in the repository folder.
ⓧ I'd like to double check the full code
Here is the complete UserRepository.java:
package com.example.taskmanager.repository;
import com.example.taskmanager.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
}
Here is the complete TaskRepository.java:
package com.example.taskmanager.repository;
import com.example.taskmanager.model.Task;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TaskRepository extends JpaRepository<Task, Long> {
List<Task> findByUserId(Long userId);
}
Create the UserDetails implementation
Spring Security does not know about your User entity by default. You need an adapter class that wraps your User and implements the UserDetails interface that Spring Security expects.
- Create a new folder: src/main/java/com/example/taskmanager/security.
- Create a file called UserDetailsImpl.java inside the security folder, then paste the following code:
package com.example.taskmanager.security;
import com.example.taskmanager.model.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
// Wraps our User entity so Spring Security can work with it
public class UserDetailsImpl implements UserDetails {
private final User user;
public UserDetailsImpl(User user) {
this.user = user;
}
// Exposes the underlying User entity for other parts of the app
public User getUser() {
return user;
}
// Converts the user's role string into a Spring Security authority
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole()));
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() { return true; }
@Override
public boolean isAccountNonLocked() { return true; }
@Override
public boolean isCredentialsNonExpired() { return true; }
@Override
public boolean isEnabled() { return true; }
}
Why wrap User in UserDetailsImpl?
Spring Security needs a UserDetails object to authenticate users. This interface requires methods like getAuthorities(), getPassword(), and getUsername(). By wrapping your User entity, you keep your domain model clean and satisfy Spring Security's contract at the same time.
The getAuthorities() method prefixes the role with ROLE_ because Spring Security expects that format for role-based authorization checks.
- Save UserDetailsImpl.java.
Now you need a service that loads a user from the database and wraps it in UserDetailsImpl. Spring Security calls this service whenever it needs to authenticate someone.
- Create a file called UserDetailsServiceImpl.java in the same security folder, then paste the following code:
package com.example.taskmanager.security;
import com.example.taskmanager.model.User;
import com.example.taskmanager.repository.UserRepository;
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;
// Spring Security calls this service to load user data during authentication
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
// Looks up the user by username, throws if not found
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return new UserDetailsImpl(user);
}
}
What does this service do?
When a user tries to log in, Spring Security calls loadUserByUsername() with the username from the login request. This method queries the database using your UserRepository and returns a UserDetailsImpl wrapper.
If no user exists with that username, it throws a UsernameNotFoundException which Spring Security converts into an authentication failure response.
- Save UserDetailsServiceImpl.java.
Compilation errors in the security package?
Check that UserDetailsServiceImpl.java imports com.example.taskmanager.repository.UserRepository. If the import is unresolved, make sure you saved UserRepository.java in the previous substep.
Help me fix compilation errors in my Spring Security UserDetails classes.
✔️ Awesome, I've got everything!
Great. Make sure both security files are saved.
ⓧ I'd like to double check the full code
Here is the complete UserDetailsImpl.java:
package com.example.taskmanager.security;
import com.example.taskmanager.model.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
public class UserDetailsImpl implements UserDetails {
private final User user;
public UserDetailsImpl(User user) {
this.user = user;
}
public User getUser() {
return user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole()));
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() { return true; }
@Override
public boolean isAccountNonLocked() { return true; }
@Override
public boolean isCredentialsNonExpired() { return true; }
@Override
public boolean isEnabled() { return true; }
}
Here is the complete UserDetailsServiceImpl.java:
package com.example.taskmanager.security;
import com.example.taskmanager.model.User;
import com.example.taskmanager.repository.UserRepository;
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;
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
public UserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return new UserDetailsImpl(user);
}
}
Time to verify everything compiles and the database tables are created.
- Compile the project by running this command in your terminal from the project root folder:
./mvnw compile
- You should see BUILD SUCCESS with no compilation errors.
- Start the application to trigger table creation by running:
./mvnw spring-boot:run
- You should see Hibernate SQL statements in the console creating the users and tasks tables.
- Open a new terminal window and verify the tables exist in MySQL by running:
mysql -u root -p
- Enter your MySQL password when prompted, then run:
USE taskmanager_db;
SHOW TABLES;
- You should see tasks and users listed in the output.
Why did the tables appear automatically?
The spring.jpa.hibernate.ddl-auto=update setting in your application.properties tells Hibernate to compare your entity classes against the database schema on startup. If tables are missing, it creates them. If columns are missing, it adds them.
Tables not showing up?
Make sure your entity classes are in the com.example.taskmanager.model package. Spring Boot only scans packages at or below the main application class (com.example.taskmanager). If your entities are in a different package tree, Hibernate will not find them.
Also confirm your MySQL credentials in application.properties are correct and the taskmanager_db database exists.
Help me debug why Hibernate is not creating my tables in MySQL.
Your data model is locked in. Next up, you will build the JWT utility that creates and validates authentication tokens.
Build the JWT Utility and Security Filter
In the previous step, you defined the data model and created the Spring Data repositories that talk to MySQL. Your app knows how to store users and tasks, but anyone can still access everything because Spring Security's default protection is just a generated password.
Now you need a way to prove who is making each request. A JWT (JSON Web Token) is a signed string your server issues after login. The client sends it back on every request, and a filter validates it before the request reaches your controllers.
In this step, get ready to:
- Create a JWT service that generates and validates tokens.
- Build a custom authentication filter that intercepts every request.
- Configure Spring Security to use your filter and open auth endpoints.
Create the JWT service
The JWT service is responsible for three things: creating a signed token from a username, extracting the username back out of a token, and checking whether a token is still valid. All of these operations use a secret key stored in application.properties.
- Create a new file at src/main/java/com/example/taskmanager/security/JwtService.java.
- Add the following code to define the JWT service:
package com.example.taskmanager.security;
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.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.function.Function;
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String secretString;
@Value("${app.jwt.expiration-ms}")
private long expirationMs;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secretString.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(UserDetails userDetails) {
return Jwts.builder()
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(getSigningKey())
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractClaim(token, Claims::getExpiration).before(new Date());
}
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
return claimsResolver.apply(claims);
}
}
What does this code do?
This service has four key responsibilities:
- getSigningKey() converts your secret string into a cryptographic key for HMAC-SHA256 signing.
- generateToken() builds a JWT with the username as the subject, timestamps for when it was issued and when it expires, then signs it.
- extractUsername() parses the token and pulls the subject claim back out.
- isTokenValid() checks that the username in the token matches and the token has not expired.
Why HMAC-SHA256 instead of RSA?
HMAC-SHA256 is a symmetric signing algorithm. One key both signs and verifies. This is perfect for a single-service API where the same server issues and validates tokens.
RSA (asymmetric) signing adds key-pair management complexity that only pays off when multiple services need to verify tokens they did not issue.
- Save JwtService.java.
Seeing import errors?
If your editor shows red underlines on the io.jsonwebtoken imports, confirm that your pom.xml has all three JJWT dependencies (jjwt-api, jjwt-impl, jjwt-jackson) and that you ran ./mvnw compile successfully in the previous step.
help me fix JJWT import errors in my JwtService
Create the JWT authentication filter
A filter sits in front of your controllers and runs on every incoming HTTP request. Your filter will look for a Bearer token in the Authorization header, validate it with the JwtService, and tell Spring Security who the user is.
- Create a new file at src/main/java/com/example/taskmanager/security/JwtAuthenticationFilter.java.
- Add the following code to define the filter:
package com.example.taskmanager.security;
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 UserDetailsServiceImpl userDetailsService;
public JwtAuthenticationFilter(JwtService jwtService, UserDetailsServiceImpl userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String jwt = authHeader.substring(7);
final String username = jwtService.extractUsername(jwt);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(jwt, 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?
Every request passes through doFilterInternal(). Here is the flow:
- If there is no Authorization header or it does not start with Bearer , the request continues unauthenticated.
- If a Bearer token exists, the filter strips the prefix, extracts the username, loads the user from the database, and validates the token signature and expiration.
- If the token is valid, it creates an authentication object and places it in the SecurityContextHolder. Downstream code can then identify the authenticated user.
What is OncePerRequestFilter?
OncePerRequestFilter is a Spring class that guarantees your filter logic runs exactly once per request, even if the request is forwarded internally. It is the standard base class for custom security filters in Spring Security.
- Save JwtAuthenticationFilter.java.
Compile error on UserDetailsServiceImpl?
Make sure your UserDetailsServiceImpl class from the previous step has the @Service annotation. Without it, Spring cannot inject it into the filter's constructor.
help me fix my JwtAuthenticationFilter dependency injection
Configure the security filter chain
Spring Security needs a configuration class that tells it how to handle requests. You will disable CSRF (not needed for a stateless API), set sessions to stateless, open the auth endpoints to everyone, and register your custom filter.
- Create a new folder config at src/main/java/com/example/taskmanager/config/.
- Create the file SecurityConfig.java inside that folder.
- Add the following code:
package com.example.taskmanager.config;
import com.example.taskmanager.security.JwtAuthenticationFilter;
import com.example.taskmanager.security.UserDetailsServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
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;
private final UserDetailsServiceImpl userDetailsService;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
UserDetailsServiceImpl userDetailsService) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.userDetailsService = userDetailsService;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
What does this configuration do?
- csrf(csrf -> csrf.disable()) turns off CSRF protection. CSRF guards browser-based form submissions, but a stateless API uses tokens instead of cookies.
- SessionCreationPolicy.STATELESS tells Spring to never create an HTTP session. Each request must carry its own token.
- requestMatchers("/api/auth/**").permitAll() lets registration and login requests through without a token.
- addFilterBefore() slots your custom JWT filter ahead of Spring's default username/password filter so tokens get validated first.
- The PasswordEncoder bean uses BCrypt to hash passwords before storing them. The AuthenticationManager bean is needed later for login credential verification.
- Save SecurityConfig.java.
- Stop the running app if it is still running (press Ctrl+C in the terminal), then restart it by running:
./mvnw spring-boot:run
- You should see Started TaskmanagerApplication in the console with no errors.
- Verify that the auth paths are open by running this in a separate terminal:
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/auth/test
You should see 404 (not 401 or 403). This confirms the auth path is open to unauthenticated requests. No endpoint exists at that path yet, so 404 is expected.
- Now verify that other paths are protected by running:
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/tasks
You should see 403. This means Spring Security is blocking unauthenticated access to protected endpoints.
App fails to start?
If you see a circular dependency or bean creation error, check that JwtAuthenticationFilter has @Component and SecurityConfig has both @Configuration and @EnableWebSecurity. Also ensure the package is com.example.taskmanager.config so Spring's component scan picks it up.
help me debug my Spring Boot app startup failure
✔️ Awesome, I've got everything!
Great. Double check you have all three files saved: JwtService.java, JwtAuthenticationFilter.java, and SecurityConfig.java.
ⓧ I'd like to double check the full code
Here are all three files in full for comparison.
package com.example.taskmanager.security;
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.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.function.Function;
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String secretString;
@Value("${app.jwt.expiration-ms}")
private long expirationMs;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secretString.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(UserDetails userDetails) {
return Jwts.builder()
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(getSigningKey())
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractClaim(token, Claims::getExpiration).before(new Date());
}
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
return claimsResolver.apply(claims);
}
}
package com.example.taskmanager.security;
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 UserDetailsServiceImpl userDetailsService;
public JwtAuthenticationFilter(JwtService jwtService, UserDetailsServiceImpl userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String jwt = authHeader.substring(7);
final String username = jwtService.extractUsername(jwt);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
package com.example.taskmanager.config;
import com.example.taskmanager.security.JwtAuthenticationFilter;
import com.example.taskmanager.security.UserDetailsServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
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;
private final UserDetailsServiceImpl userDetailsService;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
UserDetailsServiceImpl userDetailsService) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.userDetailsService = userDetailsService;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
Your security layer is now fully wired. The JWT service can mint and validate tokens, the filter intercepts every request to check for them, and the config ties everything together. Next up, you will build the registration and login endpoints that actually issue these tokens to users.
Implement Auth Endpoints
Your security infrastructure is fully wired. The JWT filter knows how to validate tokens, and the security config opens /api/auth/** to the public. The missing piece is the actual endpoints that users hit to register and log in.
In this step, you will build two endpoints that accept JSON credentials, validate them, and return a signed JWT. You will also create the DTO classes that define the shape of request and response data.
In this step, get ready to:
- Create DTO classes for request and response data.
- Build registration and login endpoints in an AuthController.
- Test the full auth flow end-to-end with curl.
Create request and response DTOs
Controllers need typed objects to deserialize incoming JSON and serialize outgoing JSON. These are called DTOs (Data Transfer Objects). You will create three: one for registration requests, one for login requests, and one for authentication responses.
- Create a new package folder at src/main/java/com/example/taskmanager/dto.
- Inside the dto package, create a new file called RegisterRequest.java and paste the following code:
package com.example.taskmanager.dto;
public class RegisterRequest {
private String username;
private String email;
private String password;
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
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; }
}
What does this class do?
Spring automatically maps incoming JSON fields to this object's properties when the controller receives a POST request. The three fields (username, email, password) match the JSON keys the client sends.
- Create another file in the dto package called LoginRequest.java and paste the following code:
package com.example.taskmanager.dto;
public class LoginRequest {
private String username;
private String password;
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; }
}
What does this class do?
Login only requires a username and password. There is no email field because the user already registered that information.
- Create one more file in the dto package called AuthResponse.java and paste the following code:
package com.example.taskmanager.dto;
public class AuthResponse {
private String token;
private String username;
public AuthResponse(String token, String username) {
this.token = token;
this.username = username;
}
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
}
What does this class do?
This is the shape of the JSON your API sends back after a successful register or login. It includes the signed JWT token and the username for the client to display.
- Save all three files.
- Verify they compile by running this command in your terminal:
./mvnw compile
- You should see BUILD SUCCESS in the terminal output.
Seeing a compilation error?
Check that each file starts with the correct package declaration: package com.example.taskmanager.dto;. The package must match the folder structure exactly.
Also confirm the folder path is src/main/java/com/example/taskmanager/dto/ with no typos in any segment.
Help me debug a compilation error in my DTO classes.
Build the AuthController with register and login endpoints
The controller is the entry point for HTTP requests. It receives JSON, delegates work to the service layer, and returns a response. Your AuthController will handle two operations: registering new users and logging in existing ones.
- Create a new package folder at src/main/java/com/example/taskmanager/controller.
- Inside the controller package, create a file called AuthController.java and paste the following code:
package com.example.taskmanager.controller;
import com.example.taskmanager.dto.AuthResponse;
import com.example.taskmanager.dto.LoginRequest;
import com.example.taskmanager.dto.RegisterRequest;
import com.example.taskmanager.model.User;
import com.example.taskmanager.repository.UserRepository;
import com.example.taskmanager.security.JwtService;
import com.example.taskmanager.security.UserDetailsImpl;
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.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
public AuthController(UserRepository userRepository,
PasswordEncoder passwordEncoder,
JwtService jwtService,
AuthenticationManager authenticationManager) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
this.authenticationManager = authenticationManager;
}
// Register a new user and return a JWT
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
if (userRepository.existsByUsername(request.getUsername())) {
return ResponseEntity.badRequest().body("Username is already taken");
}
if (userRepository.existsByEmail(request.getEmail())) {
return ResponseEntity.badRequest().body("Email is already in use");
}
User user = new User(request.getUsername(), request.getEmail(),
passwordEncoder.encode(request.getPassword()));
userRepository.save(user);
UserDetailsImpl userDetails = new UserDetailsImpl(user);
String token = jwtService.generateToken(userDetails);
return ResponseEntity.ok(new AuthResponse(token, user.getUsername()));
}
// Authenticate existing user and return a JWT
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
String token = jwtService.generateToken(userDetails);
return ResponseEntity.ok(new AuthResponse(token, userDetails.getUsername()));
}
}
What does this code do?
The @RestController annotation tells Spring this class handles HTTP requests and returns JSON. @RequestMapping("/api/auth") sets the base path for all endpoints in this controller.
The constructor injects four dependencies: UserRepository for database access, PasswordEncoder for hashing passwords, JwtService for generating tokens, and AuthenticationManager for validating credentials.
The register endpoint checks for duplicate usernames and emails, hashes the password with BCrypt, saves the user to MySQL, then immediately generates and returns a JWT so the user is logged in after registering.
The login endpoint delegates credential validation entirely to the AuthenticationManager. If the username and password are correct, it extracts the authenticated user and generates a fresh JWT.
- Save AuthController.java.
- Start the application by running:
./mvnw spring-boot:run
- You should see Started TaskmanagerApplication in the console output with no errors.
Application fails to start?
If you see an error about missing beans, double-check that your SecurityConfig.java exposes both a PasswordEncoder bean and an AuthenticationManager bean. The controller depends on both.
If you see import errors, verify the package declaration in AuthController.java is package com.example.taskmanager.controller; and that the folder structure matches.
Help me debug my AuthController startup error.
✔️ Awesome, I've got everything!
Great. Double check you have saved all four new files before moving on to testing.
ⓧ I'd like to double check the full code
package com.example.taskmanager.dto;
public class RegisterRequest {
private String username;
private String email;
private String password;
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
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; }
}
package com.example.taskmanager.dto;
public class LoginRequest {
private String username;
private String password;
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; }
}
package com.example.taskmanager.dto;
public class AuthResponse {
private String token;
private String username;
public AuthResponse(String token, String username) {
this.token = token;
this.username = username;
}
public String getToken() { return token; }
public void setToken(String token) { this.token = token; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
}
package com.example.taskmanager.controller;
import com.example.taskmanager.dto.AuthResponse;
import com.example.taskmanager.dto.LoginRequest;
import com.example.taskmanager.dto.RegisterRequest;
import com.example.taskmanager.model.User;
import com.example.taskmanager.repository.UserRepository;
import com.example.taskmanager.security.JwtService;
import com.example.taskmanager.security.UserDetailsImpl;
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.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
public AuthController(UserRepository userRepository,
PasswordEncoder passwordEncoder,
JwtService jwtService,
AuthenticationManager authenticationManager) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
this.authenticationManager = authenticationManager;
}
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
if (userRepository.existsByUsername(request.getUsername())) {
return ResponseEntity.badRequest().body("Username is already taken");
}
if (userRepository.existsByEmail(request.getEmail())) {
return ResponseEntity.badRequest().body("Email is already in use");
}
User user = new User(request.getUsername(), request.getEmail(),
passwordEncoder.encode(request.getPassword()));
userRepository.save(user);
UserDetailsImpl userDetails = new UserDetailsImpl(user);
String token = jwtService.generateToken(userDetails);
return ResponseEntity.ok(new AuthResponse(token, user.getUsername()));
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()));
UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
String token = jwtService.generateToken(userDetails);
return ResponseEntity.ok(new AuthResponse(token, userDetails.getUsername()));
}
}
Test the auth flow with curl
Your application should still be running from the previous substep. Open a second terminal window to send test requests while the server stays up.
- Register a new user by running this curl command:
curl -X POST http://localhost:8080/api/auth/register -H "Content-Type: application/json" -d '{"username":"alice","email":"alice@example.com","password":"secret123"}'
What should I see?
You should see a JSON response containing a token field and a username field. The token is a long string with three dot-separated segments. That is your signed JWT.
- Log in with the same user to verify credentials work:
curl -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" -d '{"username":"alice","password":"secret123"}'
- You should see the same JSON shape with a new token value and "username":"alice".
- Try registering with the same username again to confirm duplicate detection works:
curl -X POST http://localhost:8080/api/auth/register -H "Content-Type: application/json" -d '{"username":"alice","email":"alice2@example.com","password":"other456"}'
- You should see a 400 response with the message Username is already taken.
Getting a 403 Forbidden instead of a response?
A 403 on /api/auth/register means the security config is not permitting auth paths. Open SecurityConfig.java and confirm the .requestMatchers("/api/auth/**").permitAll() line is present inside authorizeHttpRequests.
If you see a connection refused error, make sure the application is still running in the other terminal window.
Help me debug my auth endpoint returning 403.
Why does login return a different token than register?
Each JWT contains an issuedAt timestamp. Because register and login happen at different times, the token payload differs, producing a different encoded string. Both tokens are equally valid until they expire.
Your API now issues JWTs to registered and authenticated users. Next up, you will build the protected Task endpoints that require one of these tokens to access.
Implement Protected Task CRUD Endpoints
Your authentication system is working. Users can register and log in to receive JWT tokens.
Now you'll build the feature those tokens protect: Task CRUD endpoints. Each endpoint verifies the Bearer token and ensures users can only access their own data.
In this step, get ready to:
- Create Task DTOs and a service layer with per-user data isolation.
- Build a TaskController with GET, POST, PUT, and DELETE endpoints.
- Test the complete authenticated CRUD flow end-to-end with curl.
Create the Task DTOs and service layer
The controller needs dedicated request and response shapes so it doesn't expose internal entity details to clients. You'll create a TaskRequest DTO for incoming data and a TaskResponse DTO for outgoing data.
- Create a new file at src/main/java/com/example/taskmanager/dto/TaskRequest.java and add the following code:
package com.example.taskmanager.dto;
public class TaskRequest {
private String title;
private String description;
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
What does this code do?
TaskRequest is a simple data-transfer object. It captures only the fields a client sends when creating or updating a task: title and description. The server controls everything else (ID, timestamps, ownership).
- Save TaskRequest.java.
- Create a new file at src/main/java/com/example/taskmanager/dto/TaskResponse.java and add the following code:
package com.example.taskmanager.dto;
import java.time.LocalDateTime;
public class TaskResponse {
private Long id;
private String title;
private String description;
private boolean completed;
private LocalDateTime createdAt;
public TaskResponse(Long id, String title, String description, boolean completed, LocalDateTime createdAt) {
this.id = id;
this.title = title;
this.description = description;
this.completed = completed;
this.createdAt = createdAt;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public boolean isCompleted() { return completed; }
public void setCompleted(boolean completed) { this.completed = completed; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
What does this code do?
TaskResponse defines what the API sends back to clients. It includes the task's id, title, description, completed status, and createdAt timestamp. It deliberately omits the user relationship so you never leak internal ownership details to the client.
- Save TaskResponse.java.
Next, you need a service layer that contains the business logic for task operations. This layer enforces per-user data isolation so one user can never read or modify another user's tasks.
- Create a new folder src/main/java/com/example/taskmanager/service/ and add a file called TaskService.java with the following code:
package com.example.taskmanager.service;
import com.example.taskmanager.dto.TaskRequest;
import com.example.taskmanager.dto.TaskResponse;
import com.example.taskmanager.model.Task;
import com.example.taskmanager.model.User;
import com.example.taskmanager.repository.TaskRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TaskService {
private final TaskRepository taskRepository;
public TaskService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
// Fetch only tasks that belong to the given user
public List<TaskResponse> getUserTasks(Long userId) {
return taskRepository.findByUserId(userId).stream()
.map(this::toResponse)
.toList();
}
// Create a new task owned by the given user
public TaskResponse createTask(TaskRequest request, User user) {
Task task = new Task(request.getTitle(), request.getDescription(), user);
Task saved = taskRepository.save(task);
return toResponse(saved);
}
// Update a task only if it belongs to the requesting user
public TaskResponse updateTask(Long taskId, TaskRequest request, Long userId) {
Task task = taskRepository.findById(taskId)
.orElseThrow(() -> new RuntimeException("Task not found"));
if (!task.getUser().getId().equals(userId)) {
throw new RuntimeException("Access denied");
}
task.setTitle(request.getTitle());
task.setDescription(request.getDescription());
Task updated = taskRepository.save(task);
return toResponse(updated);
}
// Delete a task only if it belongs to the requesting user
public void deleteTask(Long taskId, Long userId) {
Task task = taskRepository.findById(taskId)
.orElseThrow(() -> new RuntimeException("Task not found"));
if (!task.getUser().getId().equals(userId)) {
throw new RuntimeException("Access denied");
}
taskRepository.delete(task);
}
// Convert a Task entity to a TaskResponse DTO
private TaskResponse toResponse(Task task) {
return new TaskResponse(task.getId(), task.getTitle(), task.getDescription(),
task.isCompleted(), task.getCreatedAt());
}
}
What does this code do?
TaskService sits between the controller and the repository. Every method that reads or modifies a task checks the userId to ensure the calling user owns the resource.
The updateTask and deleteTask methods both throw a RuntimeException if a user tries to access a task they don't own. This is per-user data isolation in action.
- Save TaskService.java.
Seeing import errors?
Make sure the dto package path matches exactly: com.example.taskmanager.dto. If your IDE shows a red underline on TaskRequest or TaskResponse, confirm you saved both DTO files before opening TaskService.java.
Help me fix import errors in my TaskService
Create the TaskController
The controller wires HTTP endpoints to the service layer. Each method receives an Authentication parameter injected by Spring Security, then casts the principal to UserDetailsImpl to get the underlying User entity.
- Create a new file at src/main/java/com/example/taskmanager/controller/TaskController.java and add the following code:
package com.example.taskmanager.controller;
import com.example.taskmanager.dto.TaskRequest;
import com.example.taskmanager.dto.TaskResponse;
import com.example.taskmanager.model.User;
import com.example.taskmanager.security.UserDetailsImpl;
import com.example.taskmanager.service.TaskService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
// GET /api/tasks - list only the authenticated user's tasks
@GetMapping
public ResponseEntity<List<TaskResponse>> getTasks(Authentication authentication) {
User user = ((UserDetailsImpl) authentication.getPrincipal()).getUser();
return ResponseEntity.ok(taskService.getUserTasks(user.getId()));
}
// POST /api/tasks - create a task owned by the authenticated user
@PostMapping
public ResponseEntity<TaskResponse> createTask(@RequestBody TaskRequest request,
Authentication authentication) {
User user = ((UserDetailsImpl) authentication.getPrincipal()).getUser();
return ResponseEntity.ok(taskService.createTask(request, user));
}
// PUT /api/tasks/{id} - update a task if the user owns it
@PutMapping("/{id}")
public ResponseEntity<TaskResponse> updateTask(@PathVariable Long id,
@RequestBody TaskRequest request,
Authentication authentication) {
User user = ((UserDetailsImpl) authentication.getPrincipal()).getUser();
return ResponseEntity.ok(taskService.updateTask(id, request, user.getId()));
}
// DELETE /api/tasks/{id} - delete a task if the user owns it
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTask(@PathVariable Long id, Authentication authentication) {
User user = ((UserDetailsImpl) authentication.getPrincipal()).getUser();
taskService.deleteTask(id, user.getId());
return ResponseEntity.noContent().build();
}
}
What does this code do?
Every endpoint method takes an Authentication parameter. Spring injects this automatically because your JwtAuthenticationFilter already populated the SecurityContext with a valid authentication token for every authenticated request.
The cast ((UserDetailsImpl) authentication.getPrincipal()).getUser() retrieves the actual User entity, giving you access to the user's ID for ownership checks.
- Save TaskController.java.
- Restart the application by running:
./mvnw spring-boot:run
- You should see Started TaskmanagerApplication in the console with no compilation errors.
Application won't start?
Check that the service package folder exists at the correct path. Spring component scanning must find TaskService to inject it into the controller. Also confirm you have the @Service annotation on the class.
Help me debug why my Spring Boot app won't start after adding TaskController
✔️ Awesome, I've got everything!
Great. Double check you've saved all four new files before moving on to testing.
ⓧ I'd like to double check the full code
Here is TaskRequest.java:
package com.example.taskmanager.dto;
public class TaskRequest {
private String title;
private String description;
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
Here is TaskResponse.java:
package com.example.taskmanager.dto;
import java.time.LocalDateTime;
public class TaskResponse {
private Long id;
private String title;
private String description;
private boolean completed;
private LocalDateTime createdAt;
public TaskResponse(Long id, String title, String description, boolean completed, LocalDateTime createdAt) {
this.id = id;
this.title = title;
this.description = description;
this.completed = completed;
this.createdAt = createdAt;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public boolean isCompleted() { return completed; }
public void setCompleted(boolean completed) { this.completed = completed; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
Here is TaskService.java:
package com.example.taskmanager.service;
import com.example.taskmanager.dto.TaskRequest;
import com.example.taskmanager.dto.TaskResponse;
import com.example.taskmanager.model.Task;
import com.example.taskmanager.model.User;
import com.example.taskmanager.repository.TaskRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TaskService {
private final TaskRepository taskRepository;
public TaskService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
public List<TaskResponse> getUserTasks(Long userId) {
return taskRepository.findByUserId(userId).stream()
.map(this::toResponse)
.toList();
}
public TaskResponse createTask(TaskRequest request, User user) {
Task task = new Task(request.getTitle(), request.getDescription(), user);
Task saved = taskRepository.save(task);
return toResponse(saved);
}
public TaskResponse updateTask(Long taskId, TaskRequest request, Long userId) {
Task task = taskRepository.findById(taskId)
.orElseThrow(() -> new RuntimeException("Task not found"));
if (!task.getUser().getId().equals(userId)) {
throw new RuntimeException("Access denied");
}
task.setTitle(request.getTitle());
task.setDescription(request.getDescription());
Task updated = taskRepository.save(task);
return toResponse(updated);
}
public void deleteTask(Long taskId, Long userId) {
Task task = taskRepository.findById(taskId)
.orElseThrow(() -> new RuntimeException("Task not found"));
if (!task.getUser().getId().equals(userId)) {
throw new RuntimeException("Access denied");
}
taskRepository.delete(task);
}
private TaskResponse toResponse(Task task) {
return new TaskResponse(task.getId(), task.getTitle(), task.getDescription(),
task.isCompleted(), task.getCreatedAt());
}
}
Here is TaskController.java:
package com.example.taskmanager.controller;
import com.example.taskmanager.dto.TaskRequest;
import com.example.taskmanager.dto.TaskResponse;
import com.example.taskmanager.model.User;
import com.example.taskmanager.security.UserDetailsImpl;
import com.example.taskmanager.service.TaskService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@GetMapping
public ResponseEntity<List<TaskResponse>> getTasks(Authentication authentication) {
User user = ((UserDetailsImpl) authentication.getPrincipal()).getUser();
return ResponseEntity.ok(taskService.getUserTasks(user.getId()));
}
@PostMapping
public ResponseEntity<TaskResponse> createTask(@RequestBody TaskRequest request,
Authentication authentication) {
User user = ((UserDetailsImpl) authentication.getPrincipal()).getUser();
return ResponseEntity.ok(taskService.createTask(request, user));
}
@PutMapping("/{id}")
public ResponseEntity<TaskResponse> updateTask(@PathVariable Long id,
@RequestBody TaskRequest request,
Authentication authentication) {
User user = ((UserDetailsImpl) authentication.getPrincipal()).getUser();
return ResponseEntity.ok(taskService.updateTask(id, request, user.getId()));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTask(@PathVariable Long id, Authentication authentication) {
User user = ((UserDetailsImpl) authentication.getPrincipal()).getUser();
taskService.deleteTask(id, user.getId());
return ResponseEntity.noContent().build();
}
}
Test the full CRUD flow with curl
The real proof is hitting these endpoints with a valid Bearer token and confirming that unauthenticated requests are rejected.
- First, get a fresh JWT by logging in. Open a new terminal and run:
curl -X POST http://localhost:8080/api/auth/login -H "Content-Type: application/json" -d '{"username":"alice","password":"secret123"}'
What should I see?
You should see a JSON response with a token field and a username field. Copy the token value. If you haven't registered the user alice yet, run the register command from the previous step first.
- Create a task by running this command, replacing <your-token> with the JWT you just copied:
curl -X POST -H "Authorization: Bearer <your-token>" -H "Content-Type: application/json" -d '{"title":"Learn JWT","description":"Build a secure API"}' http://localhost:8080/api/tasks
- You should see a JSON response containing the new task's id, title, description, completed (false), and createdAt timestamp.
- List all your tasks by running:
curl -H "Authorization: Bearer <your-token>" http://localhost:8080/api/tasks
- You should see a JSON array containing the task you just created.
- Now verify that unauthenticated requests are rejected by running a request without any token:
curl http://localhost:8080/api/tasks
- You should see a 403 Forbidden response. This confirms your JWT filter is blocking unauthenticated access.
Why per-user isolation matters
If you register a second user and log in with their token, the GET /api/tasks endpoint returns an empty list. That user cannot see alice's tasks. The service layer scopes every query to the authenticated user's ID.
Getting 403 even with a token?
Make sure the Authorization header includes the word Bearer followed by a space and then the full token string. Also check that the token hasn't expired (the default expiration is 24 hours).
If you restarted the app, any previously issued tokens are still valid as long as the secret key in application.properties hasn't changed.
Help me debug why my Bearer token is returning 403
Secret mission
Add Role-Based Access Control
Your API currently treats every authenticated user the same. In production, you need different permission levels. Can you extend your JWT and security config to enforce ADMIN vs USER roles at the endpoint level?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The taskmanager_db MySQL database (stores your users and tasks tables).
- The taskmanager project folder on your Desktop (all Java source files, pom.xml, and config).
Keep everything running
No action needed. Choose this if you are still actively building or want to extend the API with more features.
- Your Spring Boot app only runs when you start it with ./mvnw spring-boot:run. It does not consume resources when stopped.
- The taskmanager_db database stays in MySQL and uses minimal disk space when idle.
- Your project folder remains on your Desktop ready for you to reopen in your editor.
Pause - I'll come back to this later
Stop the running application to free up memory. Your database and project files stay intact for when you return.
- If your Spring Boot app is still running in the terminal, press Ctrl+C to stop it.
- Close your code editor. Your files are saved and will be there when you reopen the project.
- The MySQL database persists automatically. When you come back, just rerun ./mvnw spring-boot:run from the project folder.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your database data and all source code permanently.
- If your Spring Boot app is still running, press Ctrl+C to stop it.
- Drop the MySQL database by running the following commands in your terminal:
mysql -u root -p -e "DROP DATABASE taskmanager_db;"
- Enter your MySQL root password when prompted. This removes the taskmanager_db database and all user and task data.
- Delete the project folder. On macOS/Linux, run:
rm -rf ~/Desktop/taskmanager
On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\taskmanager
- Confirm the folder is gone by running ls ~/Desktop (macOS/Linux) or dir %USERPROFILE%\Desktop (Windows). You should no longer see taskmanager in the listing.
Nice Work!
Nice Work!
Nice work! You just built a production-pattern REST API that authenticates users via self-issued JWTs, proving you can wire Spring Security 6 with custom token logic end-to-end.
You've learned how to:
- Build a Spring Boot 3.5 REST API with user registration and login endpoints that issue signed JWT tokens using JJWT 0.13.0.
- Wire a custom OncePerRequestFilter into the Spring Security 6 filter chain for stateless authentication with BCrypt password hashing.
- Build protected Task CRUD endpoints with per-user data isolation, ensuring users can only access their own tasks.
- Secret Mission: Added role-based access control with JWT claims and endpoint-level authorization.
Ready to quiz yourself?