Skip to main content

From HTML Error Pages to Beautiful JSON: Fixing JWT Authentication Errors in Spring Boot

Β· 13 min read
Mahmut Salman
Software Developer

"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 /login page 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:

ScenarioJwtAuthFilter ActionSecurityContextHandlerHTTPResponse
No token sentDoesn't set authEmptyCustomAuthenticationEntryPoint401JSON
Invalid tokenDoesn't set authEmptyCustomAuthenticationEntryPoint401JSON
Expired tokenDoesn't set authEmptyCustomAuthenticationEntryPoint401JSON
Valid token, wrong roleSets auth βœ…Has authCustomAccessDeniedHandler403JSON
Valid token, correct roleSets auth βœ…Has auth(Controller executes)200JSON
Valid auth, controller errorSets auth βœ…Has authGlobalExceptionHandler400/500JSON

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​

ComponentResponsibilityReturns
JwtAuthenticationFilterValidate token & set authenticationNothing (continues chain)
CustomAuthenticationEntryPointHandle missing/invalid authentication401 JSON
CustomAccessDeniedHandlerHandle authorization failures403 JSON
GlobalExceptionHandlerHandle business logic errors400/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​

  1. JwtAuthenticationFilter validates tokens but doesn't handle errors
  2. CustomAuthenticationEntryPoint handles authentication failures (401)
  3. CustomAccessDeniedHandler handles authorization failures (403)
  4. 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! πŸš€