Skip to main content

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:

  1. ✅ Understand how JWT works
  2. ✅ Decode the structure of a token
  3. ✅ Generate and validate tokens
  4. ✅ Implement a JWT authentication filter
  5. ✅ Manage refresh tokens

1. Why JWT?​

Session vs JWT​

AspectSessionJWT
StorageServer (memory/DB)Client (localStorage/cookie)
ScalabilityDifficult (sticky sessions)Easy (stateless)
MobileComplicatedPerfect
MicroservicesProblematicIdeal

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 ──
{
"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​

ComponentRole
JwtServiceGenerates and validates tokens
JwtAuthenticationFilterIntercepts and processes tokens
AuthControllerEndpoints 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


⬅️ Back to Module 8