Session-Based vs Token-Based Authentication
In Lecture 11, we configured HTTP Basic authentication. On every request, the client sends the username and password. This works but has a critical flaw: sending credentials with every request is insecure and impractical. Let us look at the two main alternatives.
Session-Based Authentication (Traditional Web Apps)
1. Client sends POST /login with {username, password}
2. Server verifies credentials
3. Server creates a session and stores it in memory (or Redis)
4. Server sends back a session ID in a cookie: Set-Cookie: JSESSIONID=abc123
5. Client automatically sends the cookie with every subsequent request
6. Server looks up the session by ID to identify the user
This is how traditional web applications work. The server remembers who you are by keeping session state.
Problems for REST APIs:
- Stateful — the server must store session data for every logged-in user. With 100,000 concurrent users, that is 100,000 sessions in memory.
- Not scalable — if you have multiple servers behind a load balancer, sessions must be shared (sticky sessions or a session store like Redis).
- Not suitable for mobile/SPA — cookies work naturally in browsers but are awkward in mobile apps, SPAs, and server-to-server communication.
Token-Based Authentication (Modern REST APIs)
1. Client sends POST /api/auth/login with {username, password}
2. Server verifies credentials
3. Server generates a JWT token containing user info + expiration
4. Server sends the token back in the response body
5. Client stores the token (localStorage, memory, or secure storage)
6. Client sends the token in the Authorization header with every request:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
7. Server validates the token — no session lookup needed
The client carries its own identity proof (the token). The server is completely stateless — it does not remember anything between requests. It just validates the token.
Advantages:
- Stateless — the server stores nothing. Scale horizontally by adding more servers.
- Cross-platform — the same token works for web, mobile, desktop, and server-to-server.
- Self-contained — the token carries user info (username, roles, expiration). No database lookup needed for every request.
- Decoupled — the auth server can be separate from the API server.
Comparison
| Feature | Session-Based | Token-Based (JWT) |
|---|---|---|
| Server state | Stateful (stores sessions) | Stateless (stores nothing) |
| Scalability | Requires shared session store | Scales naturally |
| Storage | Server memory / Redis | Client side |
| Cross-platform | Cookies (browser-centric) | Authorization header (universal) |
| Revocation | Easy (delete session) | Hard (token valid until expiry) |
| Best for | Traditional web apps | REST APIs, SPAs, mobile apps |
What is JWT? Structure (Header, Payload, Signature)
JWT at a Glance
A JWT (JSON Web Token, pronounced “jot”) is a compact, self-contained token that securely transmits information between parties. It looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsInJvbGUiOiJST0xFX0FVVEhPUiIsImlhdCI6MTcwNTMxMjAwMCwiZXhwIjoxNzA1Mzk4NDAwfQ.K7G_2nRt0z5F9Y8fqpXhV3cR2dJ6kL_mB8QwN1XnYz4
It is three Base64-encoded JSON objects separated by dots: header.payload.signature.
The Three Parts
Part 1: Header — Declares the token type and signing algorithm.
{
"alg": "HS256",
"typ": "JWT"
}
alg— The algorithm used to create the signature.HS256means HMAC-SHA256 (symmetric key). Other options include RS256 (RSA asymmetric).typ— Token type. Always “JWT”.
Part 2: Payload (Claims) — Contains the actual data. Claims are statements about the user.
{
"sub": "alice",
"role": "ROLE_AUTHOR",
"iat": 1705312000,
"exp": 1705398400
}
sub(subject) — Who the token represents (username or user ID)role— Custom claim: the user’s roleiat(issued at) — When the token was created (Unix timestamp)exp(expiration) — When the token expires (Unix timestamp)
Standard claims (registered): sub, iss (issuer), aud (audience), exp, iat, nbf (not before), jti (JWT ID).
Custom claims: anything you want — role, email, userId, etc.
Part 3: Signature — Ensures the token has not been tampered with.
HMAC-SHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret_key
)
The server signs the header + payload with a secret key. When a token comes back, the server re-computes the signature with the same secret. If the signatures match, the token is valid. If someone modified the payload (changed the role from USER to ADMIN), the signature will not match and the token is rejected.
Important Properties
JWTs are NOT encrypted. The header and payload are merely Base64-encoded — anyone can decode them. Do not put sensitive data (passwords, credit card numbers) in a JWT.
JWTs are tamper-proof. The signature guarantees that the content has not been modified. Changing a single character in the payload invalidates the signature.
JWTs are self-contained. The server does not need to query a database to know who the user is — the information is in the token itself.
JWT Flow in a REST API
Here is the complete flow for JWT authentication in our blog API:
Login Flow (One Time)
Client Server
| |
| POST /api/auth/login |
| {"username":"alice", |
| "password":"secret123"} |
| ─────────────────────────────────>|
| |
| 1. Verify credentials (UserDetailsService + BCrypt)
| 2. Generate JWT token with claims (sub, role, exp)
| 3. Generate refresh token
| |
| 200 OK |
| {"accessToken":"eyJ...", |
| "refreshToken":"eyJ...", |
| "expiresIn":86400} |
| <─────────────────────────────────|
| |
| Client stores tokens |
Authenticated Request Flow (Every Request)
Client Server
| |
| GET /api/posts |
| Authorization: Bearer eyJ... |
| ─────────────────────────────────>|
| |
| 1. JWT Filter extracts token from header
| 2. Validate token (check signature, expiration)
| 3. Extract username and role from claims
| 4. Set SecurityContext with user details
| 5. Request reaches controller
| |
| 200 OK |
| [{"id":1,"title":"..."}] |
| <─────────────────────────────────|
Token Refresh Flow (When Access Token Expires)
Client Server
| |
| POST /api/auth/refresh |
| {"refreshToken":"eyJ..."} |
| ─────────────────────────────────>|
| |
| 1. Validate refresh token
| 2. Generate new access token
| |
| 200 OK |
| {"accessToken":"eyJ_new...", |
| "expiresIn":86400} |
| <─────────────────────────────────|
Creating a JWT Utility Class
Add the JWT Library
We will use the jjwt library — the most popular JWT library for Java.
<!-- JWT dependencies -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
JWT Configuration Properties
Add to application.properties:
# JWT Configuration
# The secret key used to sign tokens — MUST be at least 256 bits (32 chars) for HS256.
# In production, use an environment variable: ${JWT_SECRET}
jwt.secret=my-super-secret-key-that-is-at-least-32-characters-long-for-hs256
# Access token expiration: 24 hours (in milliseconds)
jwt.access-token-expiration=86400000
# Refresh token expiration: 7 days (in milliseconds)
jwt.refresh-token-expiration=604800000
The JWT Utility Class
File: src/main/java/com/example/blogapi/security/JwtTokenProvider.java
package com.example.blogapi.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.stream.Collectors;
@Component
public class JwtTokenProvider {
private static final Logger log = LoggerFactory.getLogger(JwtTokenProvider.class);
private final SecretKey signingKey;
private final long accessTokenExpiration;
private final long refreshTokenExpiration;
// Spring injects values from application.properties.
public JwtTokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.access-token-expiration}") long accessTokenExpiration,
@Value("${jwt.refresh-token-expiration}") long refreshTokenExpiration) {
// Create the signing key from the secret string.
// Keys.hmacShaKeyFor() requires at least 256 bits (32 bytes).
this.signingKey = Keys.hmacShaKeyFor(secret.getBytes());
this.accessTokenExpiration = accessTokenExpiration;
this.refreshTokenExpiration = refreshTokenExpiration;
}
// Generate an access token from a Spring Security Authentication object.
public String generateAccessToken(Authentication authentication) {
String username = authentication.getName();
String roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
Date now = new Date();
Date expiryDate = new Date(now.getTime() + accessTokenExpiration);
return Jwts.builder()
.subject(username) // "sub" claim — who this token is for
.claim("roles", roles) // Custom claim — user roles
.issuedAt(now) // "iat" claim — when token was created
.expiration(expiryDate) // "exp" claim — when token expires
.signWith(signingKey) // Sign with our secret key
.compact(); // Build the token string
}
// Generate a refresh token (longer-lived, fewer claims).
public String generateRefreshToken(Authentication authentication) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + refreshTokenExpiration);
return Jwts.builder()
.subject(authentication.getName())
.claim("type", "refresh") // Mark as refresh token
.issuedAt(now)
.expiration(expiryDate)
.signWith(signingKey)
.compact();
}
// Extract the username from a token.
public String getUsernameFromToken(String token) {
Claims claims = parseToken(token);
return claims.getSubject();
}
// Extract roles from a token.
public String getRolesFromToken(String token) {
Claims claims = parseToken(token);
return claims.get("roles", String.class);
}
// Validate a token — checks signature, expiration, and format.
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (ExpiredJwtException ex) {
log.warn("JWT token expired: {}", ex.getMessage());
} catch (MalformedJwtException ex) {
log.warn("Invalid JWT token: {}", ex.getMessage());
} catch (UnsupportedJwtException ex) {
log.warn("Unsupported JWT token: {}", ex.getMessage());
} catch (IllegalArgumentException ex) {
log.warn("JWT claims string is empty: {}", ex.getMessage());
} catch (SecurityException ex) {
log.warn("JWT signature validation failed: {}", ex.getMessage());
}
return false;
}
// Parse and verify a token. Throws exceptions if invalid.
private Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(signingKey) // Verify the signature
.build()
.parseSignedClaims(token) // Parse and validate
.getPayload(); // Extract claims
}
public long getAccessTokenExpiration() {
return accessTokenExpiration;
}
}
Let us trace through what each method does:
generateAccessToken()— Takes a Spring SecurityAuthenticationobject (created after successful login), extracts the username and roles, creates a JWT with these claims plus expiration, signs it with the secret key.generateRefreshToken()— Similar but with longer expiration and atype:refreshclaim to distinguish it from access tokens.getUsernameFromToken()— Parses the token, verifies the signature, and extracts thesub(subject) claim.validateToken()— Tries to parse the token. If parsing succeeds (signature valid, not expired), returns true. Logs specific warnings for different failure reasons.
Building the Authentication Endpoint (/api/auth/login)
Update the Auth DTOs
File: src/main/java/com/example/blogapi/dto/AuthResponse.java (updated)
package com.example.blogapi.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class AuthResponse {
private String message;
private String accessToken;
private String refreshToken;
private String tokenType = "Bearer";
private Long expiresIn; // Seconds until access token expires
private String username;
private String role;
public AuthResponse() { }
// Constructor for login/refresh responses
public AuthResponse(String accessToken, String refreshToken,
Long expiresIn, String username, String role) {
this.message = "Authentication successful";
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.expiresIn = expiresIn;
this.username = username;
this.role = role;
}
// Constructor for registration (no tokens yet)
public AuthResponse(String message, String username, String role) {
this.message = message;
this.username = username;
this.role = role;
}
// --- Getters and Setters ---
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public String getAccessToken() { return accessToken; }
public void setAccessToken(String accessToken) { this.accessToken = accessToken; }
public String getRefreshToken() { return refreshToken; }
public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; }
public String getTokenType() { return tokenType; }
public void setTokenType(String tokenType) { this.tokenType = tokenType; }
public Long getExpiresIn() { return expiresIn; }
public void setExpiresIn(Long expiresIn) { this.expiresIn = expiresIn; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getRole() { return role; }
public void setRole(String role) { this.role = role; }
}
Update the Auth Service
File: src/main/java/com/example/blogapi/service/AuthService.java (updated login method)
@Service
public class AuthService {
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
public AuthService(UserRepository userRepository,
PasswordEncoder passwordEncoder,
AuthenticationManager authenticationManager,
JwtTokenProvider jwtTokenProvider) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.authenticationManager = authenticationManager;
this.jwtTokenProvider = jwtTokenProvider;
}
// Registration — same as Lecture 11 (no tokens returned)
@Transactional
public AuthResponse register(RegisterRequest request) {
log.info("Registering new user: username='{}'", request.getUsername());
if (userRepository.existsByUsername(request.getUsername())) {
throw new DuplicateResourceException("User", "username", request.getUsername());
}
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateResourceException("User", "email", request.getEmail());
}
User user = new User(
request.getUsername(),
request.getEmail().toLowerCase(),
passwordEncoder.encode(request.getPassword())
);
user.setFullName(request.getFullName());
user.setRole("ROLE_USER");
userRepository.save(user);
log.info("User registered: username='{}', id={}", user.getUsername(), user.getId());
return new AuthResponse("Registration successful", user.getUsername(), user.getRole());
}
// Login — now returns JWT tokens
public AuthResponse login(LoginRequest request) {
log.info("Login attempt: username='{}'", request.getUsername());
try {
// Authenticate with Spring Security
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
// Generate tokens
String accessToken = jwtTokenProvider.generateAccessToken(authentication);
String refreshToken = jwtTokenProvider.generateRefreshToken(authentication);
User user = userRepository.findByUsername(request.getUsername()).orElseThrow();
long expiresInSeconds = jwtTokenProvider.getAccessTokenExpiration() / 1000;
log.info("Login successful: username='{}'", request.getUsername());
return new AuthResponse(
accessToken,
refreshToken,
expiresInSeconds,
user.getUsername(),
user.getRole()
);
} catch (AuthenticationException ex) {
log.warn("Login failed for username='{}': {}", request.getUsername(), ex.getMessage());
throw new BadRequestException("Invalid username or password");
}
}
// Refresh — generate a new access token using a valid refresh token
public AuthResponse refreshToken(String refreshToken) {
log.info("Token refresh attempt");
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new BadRequestException("Invalid or expired refresh token");
}
String username = jwtTokenProvider.getUsernameFromToken(refreshToken);
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new BadRequestException("User not found"));
if (!user.isActive()) {
throw new BadRequestException("User account is deactivated");
}
// Create a minimal Authentication object for token generation
var authorities = Collections.singletonList(
new SimpleGrantedAuthority(user.getRole()));
var auth = new UsernamePasswordAuthenticationToken(username, null, authorities);
String newAccessToken = jwtTokenProvider.generateAccessToken(auth);
long expiresInSeconds = jwtTokenProvider.getAccessTokenExpiration() / 1000;
log.info("Token refreshed for username='{}'", username);
AuthResponse response = new AuthResponse();
response.setMessage("Token refreshed successfully");
response.setAccessToken(newAccessToken);
response.setExpiresIn(expiresInSeconds);
response.setUsername(username);
response.setRole(user.getRole());
return response;
}
}
JWT Authentication Filter
The JWT filter intercepts every HTTP request, extracts the token from the Authorization header, validates it, and sets up the Spring Security context.
File: src/main/java/com/example/blogapi/security/JwtAuthenticationFilter.java
package com.example.blogapi.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.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
// OncePerRequestFilter guarantees this filter runs exactly once per request,
// even if the request is forwarded internally.
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// Step 1: Extract the token from the Authorization header.
String token = extractTokenFromRequest(request);
// Step 2: If token exists and is valid, set up authentication.
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
// Step 3: Extract user information from the token.
String username = jwtTokenProvider.getUsernameFromToken(token);
String roles = jwtTokenProvider.getRolesFromToken(token);
// Step 4: Convert roles string to GrantedAuthority list.
List<SimpleGrantedAuthority> authorities = Arrays.stream(roles.split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// Step 5: Create an authentication object.
// The first argument is the principal (username).
// The second is credentials (null — we already verified via the token).
// The third is the list of authorities (roles).
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null, authorities);
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
// Step 6: Set the authentication in the SecurityContext.
// From this point on, Spring Security knows who the user is
// and what roles they have. @PreAuthorize and other security
// checks will work correctly.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// Step 7: Continue the filter chain.
// If the token was invalid or missing, the SecurityContext remains empty,
// and Spring Security will return 401 for protected endpoints.
filterChain.doFilter(request, response);
}
// Extract token from "Authorization: Bearer <token>" header.
private String extractTokenFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7); // Remove "Bearer " prefix
}
return null;
}
}
What Happens Step by Step
Incoming request: GET /api/posts (with Authorization: Bearer eyJ...)
1. extractTokenFromRequest() → extracts "eyJ..." from the header
2. jwtTokenProvider.validateToken("eyJ...") → verifies signature + expiration → true
3. getUsernameFromToken() → parses claims → "alice"
4. getRolesFromToken() → parses claims → "ROLE_AUTHOR"
5. Creates UsernamePasswordAuthenticationToken("alice", null, [ROLE_AUTHOR])
6. Sets SecurityContext → Spring Security now knows this is "alice" with ROLE_AUTHOR
7. filterChain.doFilter() → request continues to the controller
Incoming request: GET /api/posts (no Authorization header)
1. extractTokenFromRequest() → returns null
2. Token is null → skip authentication setup
3. filterChain.doFilter() → SecurityContext is empty
4. If endpoint is permitAll() → request reaches controller (anonymous access)
5. If endpoint is authenticated() → Spring Security returns 401
Integrating JWT Filter into Security Filter Chain
Update the SecurityConfig to register the JWT filter and remove HTTP Basic:
File: src/main/java/com/example/blogapi/security/SecurityConfig.java (complete)
package com.example.blogapi.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
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
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// Auth endpoints — public
.requestMatchers("/api/auth/**").permitAll()
// Read endpoints — public
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/categories/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/tags/**").permitAll()
// Write endpoints — require authentication (roles checked via @PreAuthorize)
.requestMatchers(HttpMethod.POST, "/api/posts/**").authenticated()
.requestMatchers(HttpMethod.PUT, "/api/posts/**").authenticated()
.requestMatchers(HttpMethod.PATCH, "/api/posts/**").authenticated()
.requestMatchers(HttpMethod.DELETE, "/api/posts/**").authenticated()
// Admin endpoints
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// Everything else — require authentication
.anyRequest().authenticated()
)
// Register our JWT filter BEFORE Spring's default auth filter.
// This ensures the JWT filter runs first and sets up the SecurityContext
// before any authorization checks happen.
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
The critical line is .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class). This inserts our JWT filter in the security filter chain, before Spring’s default username/password filter. Every request now passes through our JWT filter first.
Refresh Token Strategy
Why Refresh Tokens?
Access tokens are short-lived (24 hours in our config). When they expire, the user would need to log in again. Refresh tokens solve this:
- Access token — Short-lived (hours). Used to access protected endpoints.
- Refresh token — Long-lived (days/weeks). Used only to get a new access token.
This design limits the damage if an access token is stolen — it expires quickly. The refresh token is used less frequently and can be stored more securely.
Update the Auth Controller
File: src/main/java/com/example/blogapi/controller/AuthController.java (complete)
package com.example.blogapi.controller;
import com.example.blogapi.dto.AuthResponse;
import com.example.blogapi.dto.LoginRequest;
import com.example.blogapi.dto.RegisterRequest;
import com.example.blogapi.service.AuthService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
// POST /api/auth/register — create a new account
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(
@Valid @RequestBody RegisterRequest request) {
AuthResponse response = authService.register(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
// POST /api/auth/login — get access + refresh tokens
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(
@Valid @RequestBody LoginRequest request) {
AuthResponse response = authService.login(request);
return ResponseEntity.ok(response);
}
// POST /api/auth/refresh — get a new access token using a refresh token
@PostMapping("/refresh")
public ResponseEntity<AuthResponse> refreshToken(
@RequestBody Map<String, String> request) {
String refreshToken = request.get("refreshToken");
if (refreshToken == null || refreshToken.isBlank()) {
return ResponseEntity.badRequest().build();
}
AuthResponse response = authService.refreshToken(refreshToken);
return ResponseEntity.ok(response);
}
}
Token Lifecycle
Time 0h: Login → receive accessToken (24h) + refreshToken (7d)
Time 0-24h: Use accessToken for all API requests
Authorization: Bearer <accessToken>
Time 24h: Access token expires → API returns 401
Time 24h: Client sends refreshToken to /api/auth/refresh
→ receives new accessToken (24h more)
Time 0-7d: Can keep refreshing as long as refreshToken is valid
Time 7d: Refresh token expires → user must log in again
Advanced: Token Revocation
JWTs cannot be “invalidated” — they are valid until they expire. If you need to revoke tokens (e.g., when a user logs out or changes password), you have these options:
Option 1: Short expiration — Set access tokens to expire in 15-30 minutes. Use refresh tokens to get new ones. If you revoke the refresh token (by deleting it from the database), the user loses access when the current access token expires.
Option 2: Token blacklist — Store revoked token IDs in Redis or the database. Check the blacklist on every request. This adds a database call but provides immediate revocation.
Option 3: Token versioning — Store a tokenVersion on the user. Include it in the JWT. Increment it on logout/password change. If the token’s version does not match the user’s current version, reject it.
For our blog API, short expiration (Option 1) is sufficient. Production systems with strict security requirements may use Option 2 or 3.
Securing Endpoints — Public vs Protected
The Complete Security Matrix
Here is the final authorization matrix for our blog API:
| Endpoint | Method | Access | Notes |
|---|---|---|---|
/api/auth/register |
POST | Public | Anyone can register |
/api/auth/login |
POST | Public | Anyone can log in |
/api/auth/refresh |
POST | Public | Refresh token required in body |
/api/posts |
GET | Public | List/search posts |
/api/posts/{id} |
GET | Public | View single post |
/api/categories/** |
GET | Public | Browse categories |
/api/tags/** |
GET | Public | Browse tags |
/api/posts |
POST | AUTHOR, ADMIN | Create post |
/api/posts/{id} |
PUT | Author of post, ADMIN | Update post |
/api/posts/{id} |
DELETE | ADMIN | Delete post |
/api/posts/{id}/publish |
PATCH | Author of post, ADMIN | Publish post |
/api/posts/{id}/comments |
POST | Authenticated | Add comment |
/api/admin/** |
* | ADMIN | Admin operations |
Custom Security Exception Handling
When JWT validation fails or a user is not authorized, Spring Security returns generic error responses. Let us customize them to match our ErrorResponse format.
File: src/main/java/com/example/blogapi/security/JwtAuthenticationEntryPoint.java
package com.example.blogapi.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Map;
// This is called when an unauthenticated user tries to access a protected endpoint.
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
Map<String, Object> error = Map.of(
"status", 401,
"error", "Unauthorized",
"message", "Authentication required. Please provide a valid JWT token.",
"path", request.getRequestURI(),
"timestamp", LocalDateTime.now().toString()
);
objectMapper.writeValue(response.getOutputStream(), error);
}
}
Register it in the SecurityConfig:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ... existing config ...
// Custom 401 response
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
)
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
Hands-on: Full JWT Auth Flow for Blog API
Let us test the complete JWT flow end to end.
Step 1: Register a User
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "alice",
"email": "alice@example.com",
"password": "securepass123",
"fullName": "Alice Johnson"
}'
# Response (201 Created):
# {
# "message": "Registration successful",
# "username": "alice",
# "role": "ROLE_USER"
# }
Step 2: Login and Get Tokens
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "alice", "password": "securepass123"}'
# Response (200 OK):
# {
# "message": "Authentication successful",
# "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSIs...",
# "refreshToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSIs...",
# "tokenType": "Bearer",
# "expiresIn": 86400,
# "username": "alice",
# "role": "ROLE_USER"
# }
Save the accessToken — you will use it in the next steps. In the examples below, we use a shell variable:
TOKEN="eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSIs..."
Step 3: Access Public Endpoints (No Token Needed)
# Public endpoint — works without any token
curl http://localhost:8080/api/posts
# Expected: 200 OK with list of posts
Step 4: Access Protected Endpoints with Token
# With valid token — succeeds
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8080/api/posts/1
# Without token — 401
curl http://localhost:8080/api/posts/1/comments
# Expected 401:
# {
# "status": 401,
# "error": "Unauthorized",
# "message": "Authentication required. Please provide a valid JWT token.",
# "path": "/api/posts/1/comments"
# }
Step 5: Create a Protected Resource
# Create a comment (requires authentication)
curl -X POST http://localhost:8080/api/posts/1/comments \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"content": "Great article!", "authorId": 1}'
# Expected: 201 Created
Step 6: Test Role-Based Access
# Alice has ROLE_USER — cannot create posts (requires ROLE_AUTHOR)
curl -X POST http://localhost:8080/api/posts \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title":"Test","content":"Test content...","authorId":1}'
# Expected: 403 Forbidden (authenticated but wrong role)
Step 7: Refresh the Token
REFRESH_TOKEN="eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSIs..."
curl -X POST http://localhost:8080/api/auth/refresh \
-H "Content-Type: application/json" \
-d "{\"refreshToken\": \"$REFRESH_TOKEN\"}"
# Response (200 OK):
# {
# "message": "Token refreshed successfully",
# "accessToken": "eyJ_NEW_TOKEN...",
# "expiresIn": 86400,
# "username": "alice",
# "role": "ROLE_USER"
# }
Step 8: Test Invalid Tokens
# Expired/tampered token
curl -H "Authorization: Bearer invalid.token.here" \
http://localhost:8080/api/posts/1/comments
# Expected: 401 Unauthorized
# Missing "Bearer" prefix
curl -H "Authorization: eyJhbGciOiJIUzI1NiJ9..." \
http://localhost:8080/api/posts/1/comments
# Expected: 401 Unauthorized (filter doesn't recognize the format)
Step 9: Exercises
- Decode your JWT: Go to jwt.io and paste your access token. Verify you can see the header, payload (username, roles, expiration), and that the signature is shown as unverified (because jwt.io does not have your secret key).
- Test token expiration: Change
jwt.access-token-expirationto 10000 (10 seconds). Login, wait 15 seconds, then try to use the token. Verify you get 401. Then refresh the token and verify the new one works. - Add a logout endpoint: Create
POST /api/auth/logoutthat returns a success message. Since JWTs are stateless, the server cannot truly “invalidate” a token — the client simply discards it. Discuss with yourself: what are the limitations of this approach? - Implement token versioning: Add a
token_versionINT column to the users table. Include it in the JWT claims. On password change, increment the version. In the JWT filter, load the user and compare versions. If they do not match, reject the token. - Add CORS configuration: If a React frontend on
localhost:3000tries to call your API onlocalhost:8080, the browser blocks it. Add a CORS configuration bean that allows requests fromlocalhost:3000with theAuthorizationheader.
Summary
This lecture completed the authentication system for your blog API:
- Session vs Token: Sessions are stateful (server stores state), tokens are stateless (client carries proof). JWT tokens are the standard for REST APIs.
- JWT structure: Three Base64-encoded parts — Header (algorithm), Payload (claims like username, role, expiration), Signature (tamper-proof seal).
- JWT flow: Login → receive tokens. Subsequent requests → send access token in
Authorization: Bearerheader. Token expires → use refresh token to get a new one. - JwtTokenProvider: Generates tokens with claims, validates tokens (signature + expiration), extracts claims. Uses
jjwtlibrary with HMAC-SHA256 signing. - JwtAuthenticationFilter: Intercepts every request, extracts token from header, validates it, sets SecurityContext with user details. Extends
OncePerRequestFilter. - Security filter chain integration: JWT filter registered before
UsernamePasswordAuthenticationFilter. CSRF disabled, sessions disabled, endpoint rules defined. - Refresh tokens: Short-lived access tokens (hours) + long-lived refresh tokens (days). Limits damage from stolen tokens while keeping the user logged in.
- Custom entry point: Returns consistent JSON error responses for 401 Unauthorized, matching our
ErrorResponseformat.
What is Next
In Lecture 13, we will implement File Upload & Static Resource Handling — uploading images for blog posts and user avatars, storing file metadata in MariaDB, and serving uploaded files through the API.
Quick Reference
| Concept | Description |
|---|---|
| JWT | JSON Web Token — self-contained, signed token for authentication |
| Header | Token metadata: algorithm and type |
| Payload (Claims) | Token data: subject, roles, expiration, custom claims |
| Signature | HMAC hash of header + payload, prevents tampering |
| Access Token | Short-lived token for accessing protected endpoints |
| Refresh Token | Long-lived token for obtaining new access tokens |
Authorization: Bearer |
HTTP header format for sending JWT |
JwtTokenProvider |
Generates, validates, and parses JWT tokens |
JwtAuthenticationFilter |
Extracts and validates JWT from every request |
OncePerRequestFilter |
Base class ensuring filter runs once per request |
SecurityContextHolder |
Stores the current user’s authentication |
AuthenticationEntryPoint |
Handles 401 responses for unauthenticated requests |
jjwt |
Java JWT library (io.jsonwebtoken) |
Keys.hmacShaKeyFor() |
Creates HMAC signing key from secret bytes |
| Token revocation | JWTs cannot be invalidated — use short expiry or blacklists |
| CORS | Cross-Origin Resource Sharing — must configure for frontend apps |
