Chapter 3: JWT (JSON Web Tokens)
Introduction
Sessions are great for traditional web applications, but REST APIs need to be stateless. JWT (JSON Web Token) is the standard solution for authenticating API requests without storing sessions server-side.
🎯 Chapter Objectives
By the end of this chapter, you will know how to:
- ✅ Understand how JWT works
- ✅ Decode the structure of a token
- ✅ Generate and validate tokens
- ✅ Implement a JWT authentication filter
- ✅ Manage refresh tokens
1. Why JWT?
Session vs JWT
| Aspect | Session | JWT |
|---|---|---|
| Storage | Server (memory/DB) | Client (localStorage/cookie) |
| Scalability | Difficult (sticky sessions) | Easy (stateless) |
| Mobile | Complicated | Perfect |
| Microservices | Problematic | Ideal |
JWT Flow
2. Structure of a JWT
The 3 Parts
A JWT is composed of 3 parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
│ │ │
└──────────── HEADER ──────────────────┴────────────────────── PAYLOAD ────────────────────────────────┴── SIGNATURE ──
Header
{
"alg": "HS256", // Signature algorithm
"typ": "JWT" // Token type
}
Encoded in Base64: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Payload
{
"sub": "1234567890", // Subject (user ID)
"name": "John Doe", // Custom claims
"email": "john@example.com",
"roles": ["ROLE_USER"],
"iat": 1516239022, // Issued At (timestamp)
"exp": 1516242622 // Expiration (timestamp)
}
Signature
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret_key
)
warning
The payload is encoded, not encrypted! NEVER put sensitive data (password, credit card...) in it.
3. Implementation with Spring Boot
3.1 Dependencies
<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JJWT (Java JWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
</dependencies>
3.2 Configuration
# application.properties
jwt.secret=yourVeryLongAndComplexSecretKeyWithAtLeast256Bits
jwt.expiration=86400000
jwt.refresh-expiration=604800000
@Configuration
@ConfigurationProperties(prefix = "jwt")
@Data
public class JwtProperties {
private String secret;
private long expiration; // 24h in ms
private long refreshExpiration; // 7 days in ms
}
3.3 JwtService
@Service
@RequiredArgsConstructor
public class JwtService {
private final JwtProperties jwtProperties;
private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.getSecret());
return Keys.hmacShaKeyFor(keyBytes);
}
// Generate an Access Token
public String generateToken(UserDetails userDetails) {
return generateToken(new HashMap<>(), userDetails);
}
public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
return Jwts.builder()
.claims(extraClaims)
.subject(userDetails.getUsername())
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + jwtProperties.getExpiration()))
.signWith(getSigningKey())
.compact();
}
// Generate a Refresh Token
public String generateRefreshToken(UserDetails userDetails) {
return Jwts.builder()
.subject(userDetails.getUsername())
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + jwtProperties.getRefreshExpiration()))
.signWith(getSigningKey())
.compact();
}
// Extract the username
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
// Extract a claim
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.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
// Validate the token
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 extractExpiration(token).before(new Date());
}
private Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
}
3.4 JwtAuthenticationFilter
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// Retrieve the Authorization header
final String authHeader = request.getHeader("Authorization");
// Check Bearer format
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
// Extract the token
final String jwt = authHeader.substring(7);
final String username = jwtService.extractUsername(jwt);
// Check if not already authenticated
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// Load the user
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// Validate the token
if (jwtService.isTokenValid(jwt, userDetails)) {
// Create the authentication
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// Store in the SecurityContext
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
3.5 SecurityConfig for JWT
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final UserDetailsService userDetailsService;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // Disable CSRF for REST API
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authenticationProvider(authenticationProvider())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.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();
}
}
4. Authentication Controller
DTOs
// Login request
@Data
public class LoginRequest {
@NotBlank
private String username;
@NotBlank
private String password;
}
// Registration request
@Data
public class RegisterRequest {
@NotBlank
@Size(min = 3, max = 50)
private String username;
@NotBlank
@Email
private String email;
@NotBlank
@Size(min = 6)
private String password;
}
// Response with tokens
@Data
@Builder
public class AuthResponse {
private String accessToken;
private String refreshToken;
private String username;
private List<String> roles;
}
// Refresh request
@Data
public class RefreshTokenRequest {
@NotBlank
private String refreshToken;
}
AuthController
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final UserDetailsService userDetailsService;
private final JwtService jwtService;
private final UserService userService;
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
// Authenticate
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
// Load the user
UserDetails userDetails = userDetailsService.loadUserByUsername(request.getUsername());
// Generate tokens
String accessToken = jwtService.generateToken(userDetails);
String refreshToken = jwtService.generateRefreshToken(userDetails);
return ResponseEntity.ok(AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.username(userDetails.getUsername())
.roles(userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList())
.build());
}
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
// Create the user
User user = userService.registerUser(
request.getUsername(),
request.getEmail(),
request.getPassword()
);
// Load UserDetails
UserDetails userDetails = userDetailsService.loadUserByUsername(user.getUsername());
// Generate tokens
String accessToken = jwtService.generateToken(userDetails);
String refreshToken = jwtService.generateRefreshToken(userDetails);
return ResponseEntity.status(HttpStatus.CREATED)
.body(AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.username(user.getUsername())
.roles(List.of("ROLE_USER"))
.build());
}
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
String username = jwtService.extractUsername(request.getRefreshToken());
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(request.getRefreshToken(), userDetails)) {
String accessToken = jwtService.generateToken(userDetails);
return ResponseEntity.ok(AuthResponse.builder()
.accessToken(accessToken)
.refreshToken(request.getRefreshToken()) // Reuse
.username(username)
.roles(userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList())
.build());
}
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
5. Testing with cURL
Login
# Login
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"user","password":"password"}'
# Response:
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiJ9...",
"username": "user",
"roles": ["ROLE_USER"]
}
Protected Access
# Without token
curl http://localhost:8080/api/users
# 401 Unauthorized
# With token
curl http://localhost:8080/api/users \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..."
# 200 OK
Refresh Token
curl -X POST http://localhost:8080/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refreshToken":"eyJhbGciOiJIUzI1NiJ9..."}'
6. Best Practices
Token Storage (Frontend)
// ✅ httpOnly Cookie (most secure)
// Managed by the server
// ⚠️ localStorage (vulnerable to XSS)
localStorage.setItem('token', response.accessToken);
// ⚠️ sessionStorage (better than localStorage)
sessionStorage.setItem('token', response.accessToken);
Lifetime
Access Token: 15 minutes - 1 hour (short)
Refresh Token: 7 days - 30 days (long)
Revocation
// Option 1: Blacklist in DB
@Entity
public class RevokedToken {
private String tokenId;
private LocalDateTime revokedAt;
}
// Option 2: User version
// Changing user.tokenVersion invalidates all old tokens
7. Summary
JWT Structure
HEADER.PAYLOAD.SIGNATURE
Key Components
| Component | Role |
|---|---|
| JwtService | Generates and validates tokens |
| JwtAuthenticationFilter | Intercepts and processes tokens |
| AuthController | Endpoints login/register/refresh |
Complete Flow
1. POST /api/auth/login → Access + Refresh tokens
2. GET /api/data (Bearer token) → Data
3. Token expired → POST /api/auth/refresh → New Access token
🎯 Next Step
JWT is perfect for direct authentication, but sometimes we want to delegate authentication to a third party (Google, GitHub...). This is the role of OAuth2.
➡️ Chapter 4: OAuth2 and Social Login