Introduction
When you start building REST APIs with Spring Boot, one of the first real challenges you face is authentication. How do you make sure that only logged-in users can access certain endpoints? How do you keep the API stateless so that it can scale properly? The answer that most modern backend developers reach for is JWT — JSON Web Token.
If you already know Spring Boot but JWT feels like a black box to you — this article is written exactly for you. We are not going to just throw code at you. We will explain every piece, every class, and every decision so that by the end of this guide, you actually understand what is happening under the hood — not just copying and pasting.
By the end of this guide, you will have a fully working signup and login API secured with JWT, with every layer from the security config to the controller properly wired up, and tested step by step using Postman.
Why Not Just Use Sessions?
Before jumping into JWT, it is worth understanding why we need it in the first place. Traditional web applications use session-based authentication. When a user logs in, the server creates a session, stores it in memory or a database, and sends a session ID back to the browser as a cookie. Every subsequent request sends that cookie, and the server looks up the session to identify the user.
This approach works fine for small applications, but it has serious problems when you start scaling. If you have multiple server instances behind a load balancer, the session stored on Server A does not exist on Server B. You either need to use sticky sessions (which defeats the purpose of load balancing) or a shared session store like Redis — which adds extra infrastructure cost and complexity.
JWT solves this entirely by making authentication stateless. The token itself contains all the information the server needs — the user's email, their role, when the token was issued, and when it expires. The server does not need to store anything. It just verifies the token's signature on every request, which is a pure computation with no database lookup needed. This makes JWT-based APIs naturally scalable and perfect for microservices architectures.
What is JWT?
JWT (JSON Web Token) is an open standard (RFC 7519) that defines a compact and self-contained way to transmit information securely between parties as a JSON object. The information inside the token is digitally signed, so the server can verify that the token has not been tampered with.
A JWT looks like this:
eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6InRlc3RAZ21haWwuY29tIn0.abc123xyz
It has 3 parts separated by dots — and each part is Base64URL encoded:
Header . Payload . Signature
-
Header — Contains the algorithm used to sign the token. Most commonly
HS256(HMAC with SHA-256). It tells the server how to verify the signature. -
Payload — Contains the actual data (called "claims") like the user's email, their role, the issued-at time (
iat), and the expiry time (exp). This data is readable by anyone — it is not encrypted, just encoded. So never put sensitive data like passwords in the payload. - Signature — This is the critical part. The server creates the signature by combining the encoded header and payload with a secret key using the specified algorithm. If anyone tampers with the payload, the signature will no longer match, and the server will reject the token immediately.
A decoded JWT payload looks like this:
{
"email": "john@gmail.com",
"authorities": "ROLE_CUSTOMER",
"iat": 1775296186,
"exp": 1775382586
}
The iat is the "issued at" timestamp and exp is the expiry timestamp — both in Unix epoch seconds. When you decode the token on jwt.io, you can see all of this clearly.
How JWT Auth Flow Works
Understanding the complete flow before writing any code will save you a lot of confusion later. Here is exactly what happens from the moment a user signs up to when they access a protected route:
Step-by-step explanation:
- User sends their email and password to
/auth/loginor/auth/signup. - Server validates the credentials against the database.
- If valid, the server generates a JWT using a secret key and sends it back in the response.
- The client stores this token (in localStorage, memory, or a cookie depending on the frontend framework).
- For every subsequent request to a protected route, the client sends the token in the
Authorizationheader asBearer <token>. - The JWT Filter — a custom Spring filter — intercepts the request before it reaches any controller, extracts the token from the header, validates it, and sets the user's authentication in the
SecurityContext. - Spring Security then allows or denies the request based on the authenticated user and their role.
Project Setup
Dependencies (pom.xml)
You need three main dependency groups — Spring Security for the security framework, Spring Data JPA for database access, and the JJWT library for JWT generation and validation. We also add Lombok to reduce boilerplate code like getters, setters, and constructors.
<!-- Spring Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- JWT (JJWT Library) — split into 3 parts intentionally -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
The JJWT library is intentionally split into three parts. jjwt-api gives you the interfaces you code against, jjwt-impl is the actual implementation (marked as runtime because you only need it at runtime, not compile time), and jjwt-jackson handles JSON parsing inside the token using the Jackson library.
application.properties
spring.datasource.url=jdbc:mysql://localhost:3306/your_db
spring.datasource.username=root
spring.datasource.password=yourpassword
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
# JWT Secret — must be at least 32 characters for HS256 algorithm
jwt.secret=your_super_secret_key_minimum_32_characters_long
⚠️ Important: Never commit
application.propertieswith real credentials or your JWT secret to GitHub. Add it to.gitignoreand create anapplication.properties.examplewith placeholder values so other developers know what properties they need to configure.
Project Structure
A clean, layered project structure makes the codebase maintainable, testable, and easy to navigate — especially as the project grows. Here is how we will organize everything:
src/main/java/com/yourapp/
├── config/
│ ├── JwtProvider.java ← Generate & validate JWT tokens
│ ├── JwtTokenValidator.java ← Filter: intercepts every HTTP request
│ └── AppConfig.java ← Spring Security config (routes, CSRF, session, etc.)
├── controller/
│ └── AuthController.java ← Exposes /auth/signup and /auth/login endpoints
├── model/
│ └── User.java ← JPA entity mapped to the "users" table in MySQL
├── enums/
│ └── USER_ROLE.java ← Enum for user roles: CUSTOMER, SELLER, ADMIN
├── repository/
│ └── UserRepository.java ← Spring Data JPA repository for DB access
├── request/
│ ├── SignupRequest.java ← DTO for the signup request body
│ └── LoginRequest.java ← DTO for the login request body
├── response/
│ └── AuthResponse.java ← DTO for the auth response (JWT + message + role)
└── service/
├── AuthService.java ← Service interface
└── impl/
├── AuthServiceImpl.java ← Business logic for signup and login
└── CustomUserServiceImpl.java ← Implements UserDetailsService for Spring Security
Step 1 — User Model
The User entity is the foundation of our authentication system. It maps directly to the users table in the MySQL database. We use @Column(unique = true) on the email field to enforce uniqueness at the database level — two users cannot register with the same email address, and the database will throw a constraint violation if they try.
The role field uses an enum annotated with @Enumerated(EnumType.STRING) so that the role is stored as a readable string like ROLE_CUSTOMER in the database instead of an integer (which would be confusing and error-prone if you ever change the enum order).
@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String fullName;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
private String mobile;
@Enumerated(EnumType.STRING)
private USER_ROLE role = USER_ROLE.ROLE_CUSTOMER;
@Column(updatable = false)
private LocalDateTime createdAt = LocalDateTime.now();
}
// enums/USER_ROLE.java
public enum USER_ROLE {
ROLE_CUSTOMER,
ROLE_SELLER,
ROLE_ADMIN
}
We also define a UserRepository so Spring Data JPA can handle all the database queries automatically without writing any SQL. The findByEmail method is all we need — Spring will generate the correct SQL SELECT query automatically based on the method name convention.
// repository/UserRepository.java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
User findByEmail(String email);
}
Step 2 — Request & Response Classes
These are simple Data Transfer Objects (DTOs) that define the shape of the JSON request and response bodies. Keeping these separate from your JPA entity classes is an important design principle. It gives you full control over what data gets exposed through the API and what stays internal, and it protects you from accidentally exposing sensitive fields like the password hash in a response.
We also add Bean Validation annotations like @NotBlank and @Email to the SignupRequest so that Spring automatically validates the input and returns a meaningful error if something is missing or malformed.
// request/SignupRequest.java
@Getter
@Setter
public class SignupRequest {
@NotBlank(message = "Full name is required")
private String fullName;
@Email(message = "Enter a valid email address")
@NotBlank(message = "Email is required")
private String email;
@Size(min = 6, message = "Password must be at least 6 characters")
@NotBlank(message = "Password is required")
private String password;
}
// request/LoginRequest.java
@Getter
@Setter
public class LoginRequest {
@NotBlank(message = "Email is required")
private String email;
@NotBlank(message = "Password is required")
private String password;
}
// response/AuthResponse.java
@Getter
@Setter
public class AuthResponse {
private String jwt;
private String message;
private USER_ROLE role;
private boolean status;
}
Step 3 — JWT Provider (Generate & Validate Token)
The JwtProvider class is the heart of the entire JWT system. It is responsible for two critical operations — generating a signed token when a user logs in or signs up, and extracting the email (and verifying the signature) from a token when validating an incoming request.
The secret key is injected from application.properties using @Value, which keeps it out of your source code. We convert this string secret into a proper cryptographic Key object using Keys.hmacShaKeyFor() before using it to sign tokens.
Notice that we embed the user's email and their roles (authorities) inside the token's claims. When the token comes back in a future request, we can extract these claims and reconstruct the user's identity without ever hitting the database again during the filter phase — that is one of the key performance benefits of stateless JWT authentication.
@Component
public class JwtProvider {
@Value("${jwt.secret}")
private String jwtSecret;
// Called after successful login or signup — generates and returns a signed JWT
public String generateToken(Authentication auth) {
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
String roles = authorities.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
return Jwts.builder()
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + 86400000)) // Expires in 24 hours
.claim("email", auth.getName())
.claim("authorities", roles)
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
// Called by the JWT filter — extracts and validates the email from an incoming token
public String getEmailFromToken(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token) // This throws an exception if the token is invalid or expired
.getBody();
return String.valueOf(claims.get("email"));
}
// Converts the plain-text secret string into a cryptographic Key object for signing
private Key getSigningKey() {
byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
}
Step 4 — JWT Filter (Intercept and Validate Every Request)
This is where the magic happens for protected routes. The JwtTokenValidator extends OncePerRequestFilter, which guarantees that Spring calls it exactly once per HTTP request — before the request reaches your controller. Think of it as a security guard standing at the door checking IDs before anyone can enter.
The filter checks the Authorization header for a value starting with Bearer. If it finds one, it strips the Bearer prefix (7 characters), extracts the token, validates it using JwtProvider, and if everything checks out, it sets the authentication object in SecurityContextHolder. This is how Spring Security knows who is making the current request for the rest of the request lifecycle.
If the token is missing, the filter simply passes the request through without setting any authentication — and Spring Security will block the request if the route requires authentication. If the token is present but invalid or expired, we send back a 401 Unauthorized response immediately and stop the filter chain.
@Component
public class JwtTokenValidator extends OncePerRequestFilter {
@Autowired
private JwtProvider jwtProvider;
@Autowired
private CustomUserServiceImpl customUserService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
// Strip "Bearer " prefix (7 characters) to get the raw JWT
String token = authHeader.substring(7);
try {
// Extract email from the token payload — also validates signature and expiry
String email = jwtProvider.getEmailFromToken(token);
// Load full user details from DB using the extracted email
UserDetails userDetails = customUserService.loadUserByUsername(email);
// Create a fully authenticated token and store it in the SecurityContext
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (ExpiredJwtException e) {
// Token is valid but has expired — ask user to log in again
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token has expired. Please login again.");
return;
} catch (Exception e) {
// Token is malformed, tampered, or otherwise invalid
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token.");
return;
}
}
// Always pass the request to the next filter or the controller
filterChain.doFilter(request, response);
}
}
Step 5 — Custom UserDetailsService
Spring Security needs a way to load user details from your database during authentication. The UserDetailsService interface defines a single method — loadUserByUsername — which Spring calls internally when it needs to verify a user's identity. We implement this interface and inject our UserRepository to fetch the user from MySQL.
Even though the method is named loadUserByUsername, we use email as the unique identifier for our users — Spring Security does not enforce what you use as the "username", it just needs it to be unique. We return a Spring Security User object (not our JPA entity User) that wraps the email, the BCrypt-hashed password stored in the database, and the list of authorities (roles) the user has.
@Service
public class CustomUserServiceImpl implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
// Fetch the user entity from MySQL by email
User user = userRepository.findByEmail(email);
if (user == null) {
throw new UsernameNotFoundException(
"No account found with email: " + email + ". Please sign up first.");
}
// Map the user's role to a Spring Security GrantedAuthority
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(user.getRole().toString()));
// Return Spring Security's UserDetails object — NOT our JPA User entity
return new org.springframework.security.core.userdetails.User(
user.getEmail(),
user.getPassword(), // Already BCrypt-hashed
authorities
);
}
}
Step 6 — Security Configuration
The AppConfig class is where we configure Spring Security's behaviour for the entire application. This is arguably the most important configuration class in the project. We define which routes are publicly accessible without authentication (the /auth/** endpoints), which routes require a valid JWT, and we plug in our custom filter.
We set the session policy to STATELESS — this instructs Spring Security to never create or use an HTTP session for authentication, which is exactly what we want for a JWT-based REST API. Every single request must carry its own token. We also disable CSRF protection because CSRF attacks are only relevant for session-based authentication where the browser automatically sends cookies. With JWTs passed in the Authorization header, CSRF is simply not a concern.
@Configuration
@EnableWebSecurity
public class AppConfig {
@Autowired
private JwtTokenValidator jwtTokenValidator;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// Critical: tell Spring Security never to create HTTP sessions
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// Define route-level access control
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll() // Public: signup & login
.requestMatchers("/admin/**").hasRole("ADMIN") // Admin-only routes
.requestMatchers("/seller/**").hasRole("SELLER") // Seller-only routes
.anyRequest().authenticated() // Everything else needs a valid JWT
)
// Register our JWT filter to run before Spring's default auth filter
.addFilterBefore(jwtTokenValidator, UsernamePasswordAuthenticationFilter.class)
// Disable CSRF — not needed for stateless REST APIs with JWT
.csrf(csrf -> csrf.disable());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
// BCryptPasswordEncoder automatically handles salting and hashing
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
Step 7 — Auth Service (Business Logic)
The AuthServiceImpl class contains all the core business logic for user registration and login. This is intentionally the most detailed class — all the important decisions happen here.
For signup, we first check whether the email is already registered to prevent duplicate accounts. Then we BCrypt-encode the password (never store plain text passwords under any circumstances), save the user entity to the database, and immediately generate and return a JWT so the user is logged in right away after registering — no need for a separate login step.
For login, we load the user's details from the database using CustomUserServiceImpl, then use passwordEncoder.matches() to compare the plain-text password from the login request against the BCrypt hash stored in the database. If they match, we generate and return a fresh JWT along with the user's role and a success message.
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtProvider jwtProvider;
private final CustomUserServiceImpl customUserService;
@Override
public String createUser(SignupRequest req) {
// Step 1: Check if this email is already registered
if (userRepository.findByEmail(req.getEmail()) != null) {
throw new RuntimeException(
"An account with this email already exists. Please login instead.");
}
// Step 2: Build the new User entity
User user = new User();
user.setEmail(req.getEmail());
user.setFullName(req.getFullName());
user.setRole(USER_ROLE.ROLE_CUSTOMER);
// Step 3: ALWAYS BCrypt encode the password — never store plain text
user.setPassword(passwordEncoder.encode(req.getPassword()));
// Step 4: Persist the user to the database
userRepository.save(user);
// Step 5: Generate a JWT for the new user so they're logged in immediately after signup
List<GrantedAuthority> authorities = List.of(
new SimpleGrantedAuthority(USER_ROLE.ROLE_CUSTOMER.toString()));
Authentication auth = new UsernamePasswordAuthenticationToken(
req.getEmail(), null, authorities);
return jwtProvider.generateToken(auth);
}
@Override
public AuthResponse login(LoginRequest req) {
// Step 1: Load user from DB — throws UsernameNotFoundException if email doesn't exist
UserDetails userDetails = customUserService.loadUserByUsername(req.getEmail());
// Step 2: Compare the raw password from request with the BCrypt hash in DB
if (!passwordEncoder.matches(req.getPassword(), userDetails.getPassword())) {
throw new BadCredentialsException(
"Invalid email or password. Please check your credentials and try again.");
}
// Step 3: Create an authenticated token with the user's authorities
Authentication auth = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
// Step 4: Generate the JWT
String token = jwtProvider.generateToken(auth);
// Step 5: Build and return the response object
AuthResponse response = new AuthResponse();
response.setJwt(token);
response.setMessage("Login Success");
response.setStatus(true);
response.setRole(USER_ROLE.valueOf(
userDetails.getAuthorities().iterator().next().getAuthority()));
return response;
}
}
Step 8 — Auth Controller
The controller is the HTTP entry point for all authentication requests. Following the principle of separation of concerns, we keep it intentionally thin — no business logic lives here. The controller simply receives the HTTP request, validates the input using @Valid, delegates to the service layer, and returns the appropriate HTTP response. This makes the controller easy to read and the business logic easy to test independently.
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/signup")
public ResponseEntity<AuthResponse> signup(
@Valid @RequestBody SignupRequest request) {
String token = authService.createUser(request);
AuthResponse response = new AuthResponse();
response.setJwt(token);
response.setMessage("Account created successfully!");
response.setRole(USER_ROLE.ROLE_CUSTOMER);
response.setStatus(true);
// Return 201 Created for a successful resource creation
return new ResponseEntity<>(response, HttpStatus.CREATED);
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(
@RequestBody LoginRequest request) {
AuthResponse response = authService.login(request);
return ResponseEntity.ok(response);
}
}
Testing with Postman
Now that all the code is in place, let's verify everything works end-to-end using Postman. Make sure your Spring Boot application is running (default port 8080) and your MySQL database is connected and accessible. If you see the Hibernate table creation logs in the console on startup, you are good to go.
1. Signup — POST /auth/signup
This creates a new user account. The password will be BCrypt-encoded before being saved to the database, and a JWT token will be returned immediately so the user is already "logged in" right after signing up.
URL: http://localhost:8080/auth/signup
Headers:
Content-Type: application/json
Request Body:
{
"fullName": "John Doe",
"email": "john@gmail.com",
"password": "john1234"
}
Expected Response (201 Created):
{
"jwt": "eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6...",
"message": "Account created successfully!",
"role": "ROLE_CUSTOMER",
"status": true
}
Copy the jwt value from this response — you will need it to test protected routes. You can also paste it into jwt.io to inspect the decoded payload and verify the claims look correct.
2. Login — POST /auth/login
This authenticates an existing user using their email and password. The server looks up the user in the database, verifies the password against the stored BCrypt hash, and if everything matches, returns a fresh JWT token.
URL: http://localhost:8080/auth/login
Request Body:
{
"email": "john@gmail.com",
"password": "john1234"
}
Expected Response (200 OK):
{
"jwt": "eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6...",
"message": "Login Success",
"role": "ROLE_CUSTOMER",
"status": true
}
3. Access a Protected Route — GET /api/profile
For any request to a protected route, you must include the JWT in the Authorization header. In Postman, go to the Headers tab and add a new entry, or use the Auth tab and select Bearer Token, then paste your JWT there — Postman will add the Bearer prefix automatically.
Header (manual):
Key: Authorization
Value: Bearer eyJhbGciOiJIUzI1NiJ9...
Here is what happens in each scenario:
| Scenario | HTTP Status | Reason |
|---|---|---|
| No Authorization header | 401 Unauthorized |
JWT filter finds no token |
| Invalid or tampered token | 401 Unauthorized |
Signature verification fails |
| Expired token (past 24 hours) | 401 Unauthorized |
Token expiry check fails |
| Valid token, wrong role | 403 Forbidden |
User authenticated but not authorized |
| Valid token, correct role | 200 OK |
All checks pass |
Common Errors & Fixes
These are the most frequent issues developers run into when setting up JWT for the first time, along with the exact cause and fix for each one:
| Error | Root Cause | Fix |
|---|---|---|
401 Unauthorized on login |
Wrong password or email doesn't exist | Double-check credentials; verify passwordEncoder.matches() is used, not equals()
|
401 Unauthorized on protected route |
Missing, expired, or tampered token | Re-login to get a fresh token; make sure Bearer prefix is in the header |
403 Forbidden |
User authenticated but wrong role | Check authorizeHttpRequests config and verify the user's role in the DB |
500 NullPointerException |
Bean not injected — dependency is null | Make sure @Service, @Component, @Repository annotations are present; with @RequiredArgsConstructor all fields must be final
|
WeakKeyException on startup |
JWT secret key is too short | Use a key with at least 32 characters for HS256 algorithm |
BadCredentialsException |
Comparing plain text with hash incorrectly | Never use equals() for passwords — always use passwordEncoder.matches(rawPassword, encodedPassword)
|
| Token works in Postman but fails in frontend | Missing Bearer prefix in the Authorization header |
The full header value must be exactly Bearer followed by the token (note the space after Bearer) |
UsernameNotFoundException |
User not found in DB during login | Verify the email exists in the database; check for typos in the email |
Security Best Practices
Before you ship this to production, there are several important security considerations to keep in mind. Getting these right from the start will save you a lot of pain later.
Never store plain text passwords. This is non-negotiable. Always use BCrypt (or Argon2) to hash passwords before saving them to the database. BCrypt automatically generates and embeds a random salt into the hash, so even if two users have the same password, their stored hashes will be completely different. This means that even if your database is compromised, the attacker cannot recover the original passwords easily.
Keep your JWT secret key out of version control. The secret key is what makes your tokens trustworthy. If someone gets access to it, they can forge tokens for any user, including admins, and your entire authentication system is compromised. Store secret keys in environment variables or a dedicated secrets manager like AWS Secrets Manager, HashiCorp Vault, or Azure Key Vault — never hardcode them or commit them to GitHub.
Set a reasonable token expiry time. In this guide we used 24 hours (86400000 milliseconds). For applications that handle sensitive data like banking or healthcare, consider much shorter expiry times (15-30 minutes) and implement a refresh token mechanism for seamless re-authentication without forcing users to enter their password again.
Use HTTPS in production without exception. JWT tokens are sent in every request header. If your API runs over plain HTTP, anyone on the same network can intercept the token and use it to impersonate the user. TLS/HTTPS encrypts the entire request including headers, so the token is protected in transit.
Validate the token's claims, not just its signature. The JJWT library handles this automatically when you call parseClaimsJws() — it validates the signature AND checks the expiry. But if you ever implement token refresh or revocation, make sure you are also validating the iat (issued-at) claim against your revocation list.
Key Takeaways
- JWT is stateless — the server never stores tokens; all the information is self-contained inside the token itself, making it naturally scalable
- Always BCrypt encode passwords before saving to the database — never store or compare plain text passwords
- The JWT filter is the gatekeeper that runs before every request and sets the authentication context
- Set
SessionCreationPolicy.STATELESSso Spring Security never creates or uses HTTP sessions - The payload of a JWT is not encrypted — it is only Base64URL encoded, which anyone can decode; never put sensitive data like passwords or credit card numbers in the payload
- Keep your JWT secret key long, random, and absolutely private — treat it with the same care as a database root password
What's Next?
Now that you have a solid, production-ready foundation with JWT authentication, here are natural next steps to build on top of this system:
- Refresh Tokens — Implement a separate long-lived refresh token so users do not have to log in again every time their short-lived JWT expires. The refresh token is stored in the database and can be revoked individually.
-
Role-Based Access Control (RBAC) — Add
ROLE_SELLERandROLE_ADMINroutes and restrict access using@PreAuthorize("hasRole('ADMIN')")annotations on specific controller methods. -
Global Exception Handling — Add a
@RestControllerAdviceclass to return consistent, user-friendly JSON error responses across the entire API instead of Spring's default error format. - Email Verification — Send a one-time verification link after signup and block login for unverified accounts to prevent fake registrations.
-
Rate Limiting on Login — Protect the
/auth/loginendpoint from brute-force attacks by limiting the number of failed login attempts per IP address or email address within a time window. - Account Lockout — Automatically lock a user account after a certain number of consecutive failed login attempts and notify them via email.
If this guide helped you understand JWT better, drop a ❤️ and share it with someone who is learning Spring Boot Security. It genuinely makes a difference!

Top comments (0)