Spring Boot Mastery: Complete JWT Authentication and Authorization
This chapter covers the end-to-end implementation of JSON Web Token (JWT) authentication and authorization in a Spring Boot application. It explores the limitations of HTTP Basic Authentication, the architectural structure of JWTs, and how to seamlessly integrate JWT-based security into Spring Security 6.
1. Why Do We Need JWT? (The Problem with Basic Authentication)
In traditional HTTP Basic Authentication, the client sends the user's raw credentials (username and password) in the Authorization header of every single request.
Authorization: Basic dmlwdWw6dmlwdWw=
The Vulnerabilities of HTTP Basic Authentication
- Credential Exposure: The string
dmlwdWw6dmlwdWw=is simply a Base64-encoded representation ofusername:password. Base64 is an encoding scheme, not encryption. Anyone intercepting this header can decode it instantly to retrieve the plain-text password. - Repeated Transmission: Sending highly sensitive credentials over the wire on every API call increases the attack surface.
- Lack of Expiration: Basic authentication credentials do not expire unless the user changes their password or the session is invalidated on a stateful server.
How JWT Solves These Problems
JWT introduces a token-based, stateless authentication mechanism:
- Single Credential Exchange: The client sends the username and password only once during the login phase.
- Temporary Access: The server validates the credentials and returns a signed, time-bound JWT.
- Token-Only Requests: For all subsequent requests, the client presents the JWT in the
Authorizationheader as aBearertoken. - Cryptographic Integrity: The token is digitally signed by the server. Any tampering with the payload or expiration date invalidates the signature immediately.
2. The Core Mental Model of JSON Web Tokens (JWT)
A JSON Web Token is a compact, URL-safe string consisting of three distinct parts separated by dots (.):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ2aXB1bCIsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE2MjQyNjIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
The Three Components of a JWT
Real-World Analogy: The Event Wristband
Think of logging into an application like buying a ticket at a concert venue.
Instead of showing your government ID (username/password) to every security guard at every food stall, bar, or VIP zone, you show your ID once at the entrance. The venue staff gives you a stamped wristband (JWT) with an expiration time (e.g., "Valid for Day 1").
The stamp on the wristband (Signature) is unique to the venue's official stamp tool (Secret Key). Security guards only need to look at the wristband and verify the stamp to let you in. If you try to alter the expiration date written on the wristband, the stamp alignment breaks, and you are denied entry.
Base64URL Encoding vs. Standard Base64
JWTs are designed to be URL-safe. Standard Base64 encoding uses characters like + and /, which have special meanings in URLs and can break query parameters or path routing. JWT uses Base64URL encoding, which replaces:
+with-(minus)/with_(underscore)- It also omits padding characters (
=).
3. JWT Generation and Verification Workflow
The following sequence illustrates how a client authenticates, receives a token, and uses it to access protected resources:
4. Project Setup & Dependencies
To implement JWT in Spring Boot, add the following dependencies to your pom.xml. We use the popular JJWT (Java JWT) library by Okta/Stormpath.
<!-- JJWT API -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<!-- JJWT Implementation (Runtime Scope) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- JJWT Jackson Integration for JSON Parsing (Runtime Scope) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
Note on Runtime Scope: We only compile our code against the
jjwt-apiinterfaces. The actual implementation (jjwt-impl) and JSON processing engine (jjwt-jackson) are loaded at runtime. This decouples our code from concrete library implementations.
5. Step-by-Step Evolution: The JWT Utility Class (JwtUtil)
The JwtUtil class is a helper component responsible for generating, parsing, and validating tokens.
Cryptographic Key Requirements
Modern security standards require the signing key for HMAC SHA algorithms to be at least 256 bits (32 bytes) long. Using a weak, short password will cause runtime exceptions during key initialization.
package com.example.journalApp.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Component
public class JwtUtil {
// Must be at least 256 bits (32 characters) long
private final String SECRET_KEY = "TaK+eX9Gv8bH!zN?q2Wp5rY7sU9wX1z3A5b7C9dE";
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(SECRET_KEY.getBytes());
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
private Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
public String generateToken(String username) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, username);
}
private String createToken(Map<String, Object> claims, String subject) {
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setHeaderParam("typ", "JWT")
.setIssuedAt(new Date(System.currentTimeMillis()))
// Token valid for 60 minutes
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60))
.signWith(getSigningKey())
.compact();
}
public Boolean validateToken(String token) {
return !isTokenExpired(token);
}
}
6. User Authentication & Token Generation (Login/Signup)
We need to expose public endpoints for user registration (/signup) and authentication (/login). When a user hits /login with valid credentials, the server returns a JWT.
The Public Controller Implementation
package com.example.journalApp.controller;
import com.example.journalApp.entity.User;
import com.example.journalApp.service.UserDetailsServiceImpl;
import com.example.journalApp.utils.JwtUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/public")
@Slf4j
public class PublicController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Autowired
private JwtUtil jwtUtil;
@PostMapping("/login")
public ResponseEntity<String> login(@RequestBody User user) {
try {
// 1. Authenticate credentials using Spring Security's AuthenticationManager
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword())
);
// 2. Load user details to construct the token
UserDetails userDetails = userDetailsService.loadUserByUsername(user.getUserName());
// 3. Generate the JWT
String jwt = jwtUtil.generateToken(userDetails.getUsername());
return new ResponseEntity<>(jwt, HttpStatus.OK);
} catch (Exception e) {
log.error("Authentication failed for user: {}", user.getUserName(), e);
return new ResponseEntity<>("Incorrect username or password", HttpStatus.BAD_REQUEST);
}
}
}
7. Request Interception: The JWT Filter (JwtFilter)
The JwtFilter intercepts every incoming HTTP request to check for a valid JWT in the Authorization header. It extends OncePerRequestFilter to guarantee execution exactly once per request dispatch.
Naive vs. Optimized Token Validation
- The Naive Approach: Many tutorials extract the username from the token, query the database to load the
UserDetails, and then compare the database username with the token username. - The Optimized Approach: Since the JWT is cryptographically signed, the signature itself guarantees the integrity of the username inside the token. Querying the database on every single API call to verify a username that we extracted from a verified token is redundant and hurts performance. Instead, we simply verify the signature and check if the token has expired.
The JWT Filter Implementation
package com.example.journalApp.filter;
import com.example.journalApp.utils.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtFilter extends OncePerRequestFilter {
@Autowired
private UserDetailsService userDetailsService;
@Autowired
private JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
final String authorizationHeader = request.getHeader("Authorization");
String username = null;
String jwt = null;
// 1. Extract JWT from the Authorization header
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
jwt = authorizationHeader.substring(7);
try {
username = jwtUtil.extractUsername(jwt);
} catch (Exception e) {
logger.warn("Failed to parse or extract username from JWT: " + e.getMessage());
}
}
// 2. If username is extracted and SecurityContext is empty, validate the token
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
if (jwtUtil.validateToken(jwt)) {
// Create Spring Security Authentication Token
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// Set authentication in the security context
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
// 3. Continue the filter chain execution
chain.doFilter(request, response);
}
}
8. Spring Security Configuration (Integrating the Filter)
We must configure Spring Security to disable HTTP Basic Authentication, register our custom JwtFilter, and expose the AuthenticationManager bean.
package com.example.journalApp.config;
import com.example.journalApp.filter.JwtFilter;
import com.example.journalApp.service.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
public class SpringSecurity {
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Autowired
private JwtFilter jwtFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // Disable CSRF for stateless APIs
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll() // Allow registration and login
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
// Configure stateless session management
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
// Inject our custom JWT Filter before the standard UsernamePasswordAuthenticationFilter
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
9. Real-World Practical Pitfalls, Edge Cases, & Debugging
1. Stateless Logout
Because JWT is stateless, the server does not maintain session records. This changes how logout is handled:
- Client-Side Logout: The standard way to log out is for the client application to delete the token from its local storage (e.g.,
localStorage.removeItem('token')). Once deleted, the client can no longer make authenticated requests. - Server-Side Invalidation (Token Blacklisting): If you must invalidate a token before its natural expiration (e.g., when a user logs out or changes their password), you can use a fast in-memory store like Redis:
- When a user logs out, save their active token to Redis with a Time-To-Live (TTL) equal to the token's remaining lifespan.
- In your
JwtFilter, query Redis before processing the request. If the token is in the blacklist, reject the request with anHTTP 401 Unauthorizedstatus.
2. Token Tampering Protection
If a malicious actor intercepts a token and attempts to modify the payload (for example, changing the expiration date or altering roles), the signature verification step will fail.
Because the attacker does not have the server's private SECRET_KEY, they cannot generate a valid signature for their modified payload.
3. Debugging Flow with Breakpoints
To inspect token processing step-by-step:
- Place a breakpoint inside
JwtFilter.doFilterInternal()at the line where theAuthorizationheader is read. - Send a request from Postman with the header
Authorization: Bearer <token>. - Step through the execution to verify that the token is parsed correctly and that the
SecurityContextHolderis populated with the authenticated user's details.
Summary and Key Takeaways
- JWT is a stateless, secure standard (
RFC 7519) used to transmit information between parties as a JSON object. - Basic Authentication is insecure for modern APIs because it transmits raw credentials on every request, which can be easily decoded if not protected by HTTPS.
- A JWT consists of three parts: Header (algorithm & type), Payload (claims & metadata), and Signature (integrity verification).
- Base64URL encoding makes the token safe to pass in HTTP headers and URL query parameters by replacing non-URL-safe characters like
+and/. - Spring Security 6 integration requires disabling basic authentication, setting the session creation policy to
STATELESS, and registering a custom filter (JwtFilter) beforeUsernamePasswordAuthenticationFilter. - Signature verification guarantees integrity. You do not need to query the database to verify the username on every request; verifying the cryptographic signature is sufficient.
- Logout in stateless architectures is primarily managed client-side by destroying the token, or server-side by maintaining a blacklist in Redis.