From HTML Error Pages to Beautiful JSON: Fixing JWT Authentication Errors in Spring Boot
"Why is my API returning HTML error pages?!" I stared at my console in disbelief. My React frontend was trying to parse JSON, but Spring Security was happily serving up a Whitelabel Error Page for failed authentication attempts. This took me 3 hours to fix. Let me show you how to make Spring Security play nice with modern frontends. π¨β¨
The HTML Nightmare That Started It Allβ
Picture this: You've built a beautiful React dashboard. User logs out, token expires. They try to access a protected route. What happens?
Expected Response:
{
"status": 401,
"message": "Please log in to continue"
}
What Spring Security Actually Returns:
<!DOCTYPE html>
<html>
<head>
<title>Error</title>
</head>
<body>
<h1>Whitelabel Error Page</h1>
<p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p>
</body>
</html>
My Frontend:
fetch('/api/user/dashboard')
.then(response => response.json()) // π₯ BOOM! SyntaxError: Unexpected token '<'
.catch(error => console.error('WHY HTML?!', error));
My reaction: "Spring Security, we need to talk about our relationship with JSON." π€
The Problem: Two Worlds Collidingβ
Spring Security's Default Behaviorβ
Spring Security was designed in an era when:
- β Server-side rendered HTML was king
- β
Redirecting to
/loginpage made sense - β Browsers displayed HTML error pages beautifully
But in 2025, we're building RESTful APIs for SPAs (Single Page Applications):
- β React, Vue, Angular frontends
- β JSON-only communication
- β Client-side routing and error handling
- β No server-side HTML rendering
The disconnect: Spring Security's defaults don't match modern API requirements.
The Solution: CustomAuthenticationEntryPointβ
What We're Buildingβ
A custom error handler that transforms Spring Security's authentication failures into beautiful, frontend-friendly JSON responses.
Before:
HTTP/1.1 401 Unauthorized
Content-Type: text/html
<!DOCTYPE html>
<html>
<body>
<h1>Whitelabel Error Page</h1>
...
After:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"status": 401,
"error": "Unauthorized",
"message": "Authentication is required to access this resource. Please provide a valid JWT token.",
"path": "/api/user/dashboard"
}
Understanding the Complete JWT Authentication Flowβ
The Big Pictureβ
Before we dive into error handling, let's understand how JWT authentication actually works in Spring Security.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β HTTP REQUEST β
β GET /api/user/dashboard β
β Authorization: Bearer eyJhbGc... β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β SPRING SECURITY FILTER CHAIN β
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β 1. JwtAuthenticationFilter (CUSTOM) β β
β β β β
β β Step 1: Extract token from Authorization header β β
β β Authorization: Bearer <token> β β
β β β β
β β Step 2: Validate token β β
β β - Check signature β β
β β - Check expiration β β
β β - Extract email from token β β
β β β β
β β Step 3: Load user from database β β
β β - CustomUserDetailsService.loadUserByUsername() β β
β β - Get user's roles/authorities β β
β β β β
β β Step 4: Set authentication in SecurityContext β β
β β SecurityContextHolder.getContext() β β
β β .setAuthentication(authToken) β β
β β β β
β β β
IF VALID: Authentication set, continue to next β β
β β β IF INVALID/MISSING: No authentication set, β β
β β continue to next filter β β
β β β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β 2. Spring Security Authorization Filter β β
β β β β
β β Check: Is authentication present? β β
β β β β
β β β
YES: Check roles/permissions β β
β β β Proceed to controller β β
β β β β
β β β NO: Authentication required but not found β β
β β β Trigger CustomAuthenticationEntryPoint β‘ β β
β β β Return 401 Unauthorized β β
β β β STOP - Controller never reached β β
β β β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β CONTROLLER LAYER β
β (Only reached if authenticated and authorized) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The Key Components Explainedβ
Component 1: JwtAuthenticationFilterβ
Location: src/main/java/com/ecommerce/app/security/JwtAuthenticationFilter.java
Job: Extract JWT token, validate it, and set authentication if valid.
What It Does (and Doesn't Do)β
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) {
try {
// 1. Extract token from "Authorization: Bearer <token>" header
String jwt = extractTokenFromRequest(request);
// 2. If token exists AND is valid, set authentication
if (jwt != null && jwtUtil.validateToken(jwt, extractEmailFromToken(jwt))) {
String email = extractEmailFromToken(jwt);
// Load user details from database
UserDetails userDetails = customUserDetailsService.loadUserByUsername(email);
// Create authentication token with user's authorities (roles)
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities() // β ROLE_USER or ROLE_ADMIN
);
// Set authentication in SecurityContext
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// β οΈ IMPORTANT: If token is invalid/missing, we do NOT throw error
// We just don't set authentication and continue the filter chain
} catch (Exception ex) {
// Log error but don't fail - let Spring Security handle it
logger.error("Cannot set user authentication", ex);
}
// β
ALWAYS continue to next filter
filterChain.doFilter(request, response);
}
}
Critical Understanding β‘β
JwtAuthenticationFilter is NOT responsible for:
- β Throwing errors for invalid tokens
- β Returning HTTP 401 responses
- β Stopping the filter chain
- β Handling authentication failures
JwtAuthenticationFilter IS responsible for:
- β Extracting the token
- β Validating the token
- β Setting authentication IF VALID
- β Always continuing the filter chain
What Happens After JwtAuthenticationFilter?β
JwtAuthenticationFilter finishes
β
SecurityContext checked by Spring Security
β
Is authentication present?
β
ββββββββββββββββββββββββββββββββββββββββ
β YES - Authentication is set β
β β
Token was valid β
β β
User is authenticated β
β β Continue to authorization check β
ββββββββββββββββββββββββββββββββββββββββ
ββββββββββββββββββββββββββββββββββββββββ
β NO - Authentication is NOT set β
β β No token provided β
β β Token was invalid β
β β Token was expired β
β β Spring Security detects this β
β β Calls AuthenticationEntryPoint β‘ β
β β Returns 401 Unauthorized β
ββββββββββββββββββββββββββββββββββββββββ
The revelation: JwtAuthenticationFilter is just a validator. Spring Security's AuthenticationEntryPoint is the one that handles failures!
Component 2: CustomAuthenticationEntryPointβ
Location: src/main/java/com/ecommerce/app/security/CustomAuthenticationEntryPoint.java
Job: Handle cases where authentication is required but not present/invalid.
The Implementationβ
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
// Set 401 status
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
// Create custom JSON error response
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("status", 401);
errorResponse.put("error", "Unauthorized");
errorResponse.put("message", "Authentication is required to access this resource. Please provide a valid JWT token.");
errorResponse.put("path", request.getRequestURI());
// Write JSON to response
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
}
Registering in SecurityConfigβ
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ... other config ...
.exceptionHandling(exception -> exception
.authenticationEntryPoint(customAuthenticationEntryPoint) // β Magic happens here
);
return http.build();
}
}
Before vs After: Real-World Scenariosβ
Scenario 1: User Forgot to Send Tokenβ
The Frontend Code:
// User clicks "My Dashboard" without being logged in
async function loadDashboard() {
const response = await fetch('/api/user/dashboard');
const data = await response.json();
// Display data...
}
BEFORE CustomAuthenticationEntryPointβ
Response:
HTTP/1.1 401 Unauthorized
Content-Type: text/html
<!DOCTYPE html>
<html>
<head>
<title>Error</title>
</head>
<body>
<h1>Whitelabel Error Page</h1>
<p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p>
</body>
</html>
Frontend Console:
β SyntaxError: Unexpected token '<' in JSON at position 0
User Experience:
- β Blank screen or crashed app
- β No error message displayed
- β User has no idea what went wrong
- β Developer gets cryptic error in console
AFTER CustomAuthenticationEntryPointβ
Response:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"status": 401,
"error": "Unauthorized",
"message": "Authentication is required to access this resource. Please provide a valid JWT token.",
"path": "/api/user/dashboard"
}
Improved Frontend Code:
async function loadDashboard() {
try {
const response = await fetch('/api/user/dashboard');
const data = await response.json();
if (response.status === 401) {
// Show login modal with friendly message
showLoginModal(data.message);
return;
}
// Display dashboard data
renderDashboard(data);
} catch (error) {
console.error('Error:', error);
}
}
User Experience:
- β Clean JSON response parsed successfully
- β Friendly modal: "Authentication is required to access this resource"
- β User knows exactly what to do (log in)
- β Professional, polished experience
Scenario 2: Token Expired While User Was Activeβ
The Story:
- User logged in yesterday
- Token valid for 24 hours
- User comes back today and tries to delete something
- Token is now expired
The Request:
DELETE /api/admin/users/5
Authorization: Bearer <expired-token>
BEFOREβ
Response:
HTTP/1.1 401 Unauthorized
(Empty body or HTML error page)
Frontend:
fetch('/api/admin/users/5', { method: 'DELETE' })
.then(response => response.json()) // β Fails to parse
.catch(error => {
// Generic error - user sees nothing or cryptic message
console.error('Error:', error);
});
AFTERβ
Response:
{
"status": 401,
"error": "Unauthorized",
"message": "Authentication is required to access this resource. Please provide a valid JWT token.",
"path": "/api/admin/users/5"
}
Improved Frontend:
async function deleteUser(id) {
try {
const response = await fetch(`/api/admin/users/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${getToken()}`
}
});
const data = await response.json();
if (response.status === 401) {
// Token expired - refresh or redirect to login
console.log('Session expired:', data.message);
await refreshTokenOrRedirectToLogin();
return;
}
if (response.ok) {
showSuccess('User deleted successfully');
}
} catch (error) {
console.error('Error:', error);
}
}
User Experience:
- β Detects expired session gracefully
- β Shows message: "Your session has expired. Please log in again."
- β Automatically refreshes token or redirects to login
- β User doesn't lose their work
Scenario 3: Malicious/Invalid Tokenβ
The Request:
GET /api/user/dashboard
Authorization: Bearer totally_fake_token_123
BEFOREβ
Response:
HTTP/1.1 401 Unauthorized
(Inconsistent - sometimes HTML, sometimes empty)
AFTERβ
Response:
{
"status": 401,
"error": "Unauthorized",
"message": "Authentication is required to access this resource. Please provide a valid JWT token.",
"path": "/api/user/dashboard"
}
Security Benefits:
- β Consistent error response (doesn't reveal internal details)
- β Logs invalid token attempts server-side
- β Clean error message to client
- β No information leakage about token validation process
Complete Error Handling Matrixβ
Understanding who handles what in your Spring Security setup:
| Scenario | JwtAuthFilter Action | SecurityContext | Handler | HTTP | Response |
|---|---|---|---|---|---|
| No token sent | Doesn't set auth | Empty | CustomAuthenticationEntryPoint | 401 | JSON |
| Invalid token | Doesn't set auth | Empty | CustomAuthenticationEntryPoint | 401 | JSON |
| Expired token | Doesn't set auth | Empty | CustomAuthenticationEntryPoint | 401 | JSON |
| Valid token, wrong role | Sets auth β | Has auth | CustomAccessDeniedHandler | 403 | JSON |
| Valid token, correct role | Sets auth β | Has auth | (Controller executes) | 200 | JSON |
| Valid auth, controller error | Sets auth β | Has auth | GlobalExceptionHandler | 400/500 | JSON |
Key Insight: Authentication (401) vs Authorization (403) are handled by different components!
Frontend Integration: Real React Exampleβ
Complete React Authentication Hookβ
// hooks/useAuth.js
import { useState, useEffect } from 'react';
export function useAuth() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
checkAuth();
}, []);
async function checkAuth() {
const token = localStorage.getItem('jwt');
if (!token) {
setLoading(false);
return;
}
try {
const response = await fetch('/api/user/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
const data = await response.json();
if (response.status === 401) {
// Token invalid or expired
console.log('Session expired:', data.message);
localStorage.removeItem('jwt');
setUser(null);
} else if (response.ok) {
setUser(data);
}
} catch (error) {
console.error('Auth check failed:', error);
} finally {
setLoading(false);
}
}
async function login(email, password) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (response.ok) {
localStorage.setItem('jwt', data.token);
setUser(data.user);
return { success: true };
} else {
return {
success: false,
message: data.message // Clean error message from backend
};
}
}
function logout() {
localStorage.removeItem('jwt');
setUser(null);
}
return { user, loading, login, logout, checkAuth };
}
Protected Route Componentβ
// components/ProtectedRoute.jsx
import { Navigate } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';
export function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
if (loading) {
return <div>Loading...</div>;
}
if (!user) {
return <Navigate to="/login" />;
}
return children;
}
API Helper with Error Handlingβ
// utils/api.js
export async function apiRequest(url, options = {}) {
const token = localStorage.getItem('jwt');
const config = {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` }),
...options.headers
}
};
try {
const response = await fetch(url, config);
const data = await response.json();
if (response.status === 401) {
// Authentication failed - redirect to login
localStorage.removeItem('jwt');
window.location.href = '/login';
throw new Error(data.message);
}
if (response.status === 403) {
// Authorization failed - show error
throw new Error(data.message);
}
if (!response.ok) {
throw new Error(data.message || 'Request failed');
}
return data;
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
Why This Matters: The Real Benefitsβ
1. Frontend-Friendly Error Handling β¨β
Before: Parsing HTML, handling unpredictable error formats, cryptic console errors
After: Clean JSON, consistent structure, meaningful error messages
2. Better User Experience π―β
Before:
- Blank screens
- "Something went wrong" (unhelpful)
- App crashes
After:
- "Your session expired. Please log in again."
- "Access denied. Contact your administrator."
- Smooth error handling with clear next steps
3. Easier Debugging πβ
Before:
β SyntaxError: Unexpected token '<'
(Where? Why? What failed?)
After:
{
"status": 401,
"error": "Unauthorized",
"message": "Authentication is required to access this resource. Please provide a valid JWT token.",
"path": "/api/user/dashboard"
}
(Crystal clear what failed and where)
4. RESTful API Best Practices πβ
Industry Standard:
- β All responses in JSON (including errors)
- β Proper HTTP status codes
- β Meaningful error messages
- β Consistent error format
5. Production-Ready πβ
Professional APIs:
- β Handle authentication failures gracefully
- β Return structured error responses
- β Don't expose internal implementation details
- β Provide client-friendly error messages
Common Pitfalls and How to Avoid Themβ
Pitfall 1: Throwing Exceptions in JwtAuthenticationFilterβ
β Don't Do This:
@Override
protected void doFilterInternal(...) {
String jwt = extractToken(request);
if (jwt == null) {
throw new AuthenticationException("No token found"); // β DON'T!
}
// This stops the filter chain and causes issues
}
β Do This:
@Override
protected void doFilterInternal(...) {
String jwt = extractToken(request);
if (jwt != null && isValid(jwt)) {
setAuthentication(jwt); // β
Only set if valid
}
// Always continue filter chain
filterChain.doFilter(request, response);
}
Pitfall 2: Returning Different Error Formatsβ
β Inconsistent:
// Sometimes JSON
{"status": 401, "message": "Unauthorized"}
// Sometimes plain text
"Unauthorized"
// Sometimes HTML
<html><body>Error</body></html>
β Always JSON:
// CustomAuthenticationEntryPoint - JSON
// CustomAccessDeniedHandler - JSON
// GlobalExceptionHandler - JSON
// All errors consistent!
Pitfall 3: Exposing Sensitive Informationβ
β Too Much Detail:
{
"message": "JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted."
}
β Generic but Helpful:
{
"message": "Authentication is required to access this resource. Please provide a valid JWT token."
}
(Log detailed errors server-side, return generic messages to client)
Testing Your Implementationβ
Test 1: No Tokenβ
curl -X GET http://localhost:8080/api/user/dashboard
Expected Response:
{
"status": 401,
"error": "Unauthorized",
"message": "Authentication is required to access this resource. Please provide a valid JWT token.",
"path": "/api/user/dashboard"
}
Test 2: Invalid Tokenβ
curl -X GET http://localhost:8080/api/user/dashboard \
-H "Authorization: Bearer invalid_token_123"
Expected Response:
{
"status": 401,
"error": "Unauthorized",
"message": "Authentication is required to access this resource. Please provide a valid JWT token.",
"path": "/api/user/dashboard"
}
Test 3: Valid Tokenβ
curl -X GET http://localhost:8080/api/user/dashboard \
-H "Authorization: Bearer <valid-jwt-token>"
Expected Response:
{
"username": "john_doe",
"email": "john@example.com",
"role": "USER"
}
Quick Reference Guideβ
Component Responsibilitiesβ
| Component | Responsibility | Returns |
|---|---|---|
| JwtAuthenticationFilter | Validate token & set authentication | Nothing (continues chain) |
| CustomAuthenticationEntryPoint | Handle missing/invalid authentication | 401 JSON |
| CustomAccessDeniedHandler | Handle authorization failures | 403 JSON |
| GlobalExceptionHandler | Handle business logic errors | 400/500 JSON |
Decision Treeβ
Request arrives
β
JwtAuthenticationFilter: Is token valid?
ββ YES β Set authentication β Continue
ββ NO β Don't set authentication β Continue
β
Spring Security: Is authentication required?
ββ NO β Allow request β Controller
ββ YES β Is authentication present?
ββ YES β Check authorization β Controller or 403
ββ NO β CustomAuthenticationEntryPoint β 401
Summary: The Complete Picture π―β
What We Learnedβ
- JwtAuthenticationFilter validates tokens but doesn't handle errors
- CustomAuthenticationEntryPoint handles authentication failures (401)
- CustomAccessDeniedHandler handles authorization failures (403)
- All three work together for complete error handling
The Problem We Solvedβ
Before: HTML error pages breaking frontend applications
After: Clean JSON responses for professional API error handling
Why It Mattersβ
- β Better user experience
- β Easier frontend integration
- β RESTful API best practices
- β Production-ready authentication
- β Consistent error handling
The Main Insightβ
CustomAuthenticationEntryPoint isn't just about showing pretty messages - it's about making your Spring Boot application compatible with modern frontend frameworks by returning JSON instead of HTML for all authentication failures.
What's Next?β
Now that you have solid JWT error handling, consider:
- Implementing token refresh mechanism
- Adding rate limiting for failed auth attempts
- Implementing account lockout after multiple failures
- Adding detailed audit logging
- Implementing CORS properly for frontend integration
Questions or improvements? Let me know in the comments! π
Found this helpful? Share it with developers struggling with Spring Security JWT errors! π
