Spring Security exception handling is done via AuthenticationEntryPoint for 401s, AccessDeniedHandler for 403s, and a global @ControllerAdvice with ResponseEntityExceptionHandler for unhandled exceptions. Use a custom AuthenticationFailureHandler for login failures. Never rely on default error pages in production.
✦ Definition~90s read
What is Handling Spring Security Exceptions with @ExceptionHandler and AccessDeniedHandler?
Spring Security exception handling is the mechanism by which your application responds to authentication and authorization failures. It's not just about catching exceptions — it's about controlling the HTTP response status codes, bodies, and headers that get sent back to clients.
★
Think of Spring Security as a bouncer at a club.
The key interfaces are AuthenticationEntryPoint (for unauthenticated requests), AccessDeniedHandler (for unauthorized requests), and AuthenticationFailureHandler (for login failures). You can also use @ControllerAdvice but only for exceptions that escape the security filter chain.
Plain-English First
Think of Spring Security as a bouncer at a club. When someone tries to enter without a ticket (no auth), the bouncer says 'Get lost' (401). When someone has a ticket but tries to enter the VIP area (no permission), the bouncer says 'You can't go there' (403). Exception handlers are like the bouncer's communication system — they make sure the right message gets delivered in the right format (JSON, HTML, etc).
I've spent the last 15 years building payment processing systems handling millions of dollars in transactions. Here's the hard truth: most teams get this wrong. They slap a @ControllerAdvice on a class, think they're done, and then wake up at 3 AM to a PagerDuty alert because their API returns a raw HTML login page when a JWT expires. Let me be blunt: if you're not explicitly handling every security exception path, you are one deploy away from a production incident. This tutorial covers Spring Boot 3.2+, Spring Security 6.x, and the exact patterns that have kept my systems running for years.
Setting Up the Project
Let's start with a Spring Boot 3.2.5 project using Spring Security 6.3.0. I'm using Maven, but Gradle works too. The key dependency is spring-boot-starter-security. Add it to your pom.xml. For this tutorial, we'll build a simple REST API for a SaaS billing platform. We'll have endpoints for public info, user profile, and admin billing. Stop using the default security configuration — it's a trap that will burn you. I've seen this blow up in production when someone forgets to configure CORS and the default security kicks in, blocking all cross-origin requests with a 403 and no explanation.
Project created with Spring Boot 3.2.5 and Spring Security 6.3.0
⚠ Version Compatibility
📊 Production Insight
In production, pin your dependency versions. I've seen a minor Spring Security patch break custom filters. Use a Bill of Materials (BOM) to manage versions consistently.
🎯 Key Takeaway
Use Spring Boot 3.2.5+ and Spring Security 6.3.0+ for the latest security features and patches.
What the Official Docs Won't Tell You
The official Spring Security documentation is excellent for basic setups, but it glosses over the ugly reality of production exception handling. Here's what they don't tell you: the default exception handling is designed for browser-based apps, not REST APIs. When an unauthenticated request hits a secured endpoint, Spring Security's default behavior is to redirect to a login page. For a REST API, that means your client gets a 302 redirect to /login — which is probably not what you want. I've seen this blow up in production when a mobile app received a 302 and tried to parse the HTML login page as JSON, causing a cryptic parse error that took days to debug. Another hidden gotcha: the ExceptionTranslationFilter catches AuthenticationException and AccessDeniedException, but only if they're thrown AFTER the filter chain. Exceptions thrown before the chain (like in a custom filter) may not be handled at all. You need to manually delegate to AuthenticationEntryPoint or AccessDeniedHandler in those cases.
This configuration uses default exception handling. Unauthenticated requests to /api/admin will get a 302 redirect to /login, not a JSON 401.
💡The Redirect Trap
📊 Production Insight
Add a test that verifies your API returns 401 (not 302) for unauthenticated requests. I've caught this regression three times in CI/CD pipelines.
🎯 Key Takeaway
Default Spring Security exception handling is for browser apps. For REST APIs, you MUST customize AuthenticationEntryPoint and AccessDeniedHandler.
Custom AuthenticationEntryPoint for 401 Errors
Let me be blunt: if you're still using the default AuthenticationEntryPoint in a production REST API, you're doing it wrong. The default implementation (LoginUrlAuthenticationEntryPoint) redirects to a login page. For a JSON API, you need to return a 401 with a structured error body. Here's the correct approach: implement AuthenticationEntryPoint interface and write a JSON response manually. I always include a unique error code (like 'AUTH-001') and a timestamp for debugging. Never expose internal details like 'Bad credentials' vs 'User not found' — that's a security risk that helps attackers enumerate valid usernames. Use a generic message like 'Authentication failed' and log the real reason server-side.
When an unauthenticated request hits a secured endpoint, the response will be a 401 with JSON body: {"error":"Unauthorized","message":"Authentication is required...","code":"AUTH-001","timestamp":"2025-04-07T12:00:00Z","path":"/api/admin/billing"}
🔥Why Generic Messages?
📊 Production Insight
Add a correlation ID (UUID) to every error response. In production, we log the full exception with this ID, so when a client reports an error, we can trace it instantly.
🎯 Key Takeaway
Custom AuthenticationEntryPoint returns 401 with a generic JSON error body. Never expose internal authentication details.
Custom AccessDeniedHandler for 403 Errors
Stop using the default AccessDeniedHandler. The default sends a 403 with an empty body — which is useless for debugging and dangerous for clients that don't check response codes. I've seen this blow up in production when a payment gateway integration silently failed because the client treated a 200 as success and a 403 as 'no data' instead of an error. Implement AccessDeniedHandler and return a JSON body with an error code, message, and the required role or permission. This helps frontend developers show meaningful error messages like 'You need admin access to perform this action.' Also include the current user's roles in the response (but never in production logs — only in debug mode).
When an authenticated user without ADMIN role hits /api/admin, they get a 403 with JSON: {"error":"Forbidden","message":"You do not have permission...","code":"AUTH-003","required_role":"ADMIN","timestamp":"2025-04-07T12:00:00Z","path":"/api/admin/billing"}
⚠ Empty 403 Bodies Are Evil
📊 Production Insight
In our billing system, we include the resource ID and the action attempted (READ, WRITE, DELETE) in the 403 response. This helps support teams quickly identify permission issues.
🎯 Key Takeaway
Custom AccessDeniedHandler returns 403 with a JSON body including required role. Never return empty body for 403.
Global Exception Handling with @ControllerAdvice
Here's the hard truth: @ControllerAdvice is great for handling exceptions thrown by your controllers, but it WON'T catch exceptions thrown by Spring Security's filter chain. The filter chain executes before your controllers, so any security exception that escapes the filters won't reach your @ControllerAdvice. That's why you need both: custom handlers for security exceptions (as shown above) and @ControllerAdvice for business logic exceptions. For the @ControllerAdvice, extend ResponseEntityExceptionHandler to override default Spring MVC error responses. I always add handlers for MethodArgumentNotValidException (validation errors) and MissingServletRequestParameterException (missing params). Use a consistent error format across all exception types — your frontend team will thank you.
A validation error on a request body returns 400 with JSON: {"error":"Validation Failed","message":"email: must be a well-formed email address","code":"VAL-001","timestamp":"2025-04-07T12:00:00Z","path":"/api/user/profile"}
💡@ControllerAdvice Limitations
📊 Production Insight
I always add a catch-all handler for Exception class in @ControllerAdvice that returns 500 with a generic message and logs the full stack trace. This prevents sensitive data leakage in production.
🎯 Key Takeaway
Use @ControllerAdvice for business logic exceptions only. Security exceptions must be handled by custom AuthenticationEntryPoint and AccessDeniedHandler.
Integrating Custom Handlers with SecurityConfig
Now we wire everything together in the SecurityFilterChain configuration. This is where most teams make a critical mistake: they add the custom handlers but forget to disable the default exception handling. You must explicitly set the AuthenticationEntryPoint and AccessDeniedHandler using http.exceptionHandling(). Also, disable the default form login and httpBasic if you're using JWT or OAuth2. I've seen this blow up in production when someone added a custom handler but left .formLogin() enabled, causing a race condition where some requests got JSON responses and others got HTML login pages. Use .exceptionHandling() to configure both handlers. Also, remember to disable CSRF for REST APIs (unless you're using cookies for auth).
The SecurityConfig now uses custom handlers for both 401 and 403. All security exceptions return JSON responses instead of HTML redirects.
⚠ Disable Form Login for REST APIs
📊 Production Insight
In our production config, we also add a custom filter that logs every security exception with a correlation ID before delegating to the handlers. This gives us full traceability.
🎯 Key Takeaway
Wire custom handlers via http.exceptionHandling() and disable default form login. Use stateless sessions for REST APIs.
Testing Exception Handling with MockMvc
Stop testing exception handling manually by hitting endpoints with Postman. Write automated tests using MockMvc that verify the exact JSON response for each security scenario. I've seen this blow up in production when a developer refactored the exception handler and forgot to update the JSON field names, causing the frontend to break silently. Use @WithMockUser for authenticated requests and test unauthenticated requests without it. Test three scenarios: unauthenticated (expect 401), authenticated but unauthorized (expect 403), and authenticated and authorized (expect 200). Also test that validation errors from @ControllerAdvice return the correct format. If you're doing this right, your test suite should fail if someone accidentally reverts to default security handling.
All three tests pass. Unauthenticated returns 401 JSON, unauthorized returns 403 JSON, authorized returns 200.
🔥Test All Three Scenarios
📊 Production Insight
Add a CI pipeline step that runs these tests with different Spring profiles (e.g., 'dev', 'staging') to catch environment-specific security config issues.
🎯 Key Takeaway
Use MockMvc with @WithMockUser to automate testing of exception handling. Test unauthenticated, unauthorized, and authorized scenarios.
Advanced: Custom Exception Handling for JWT Authentication
If you're using JWT (and you should be for stateless APIs), you need to handle token validation exceptions like ExpiredJwtException, MalformedJwtException, and SignatureException. The default behavior is to throw an AuthenticationException that gets caught by the AuthenticationEntryPoint — but the specific exception type is lost. I've seen this blow up in production when a mobile app couldn't distinguish between 'token expired' and 'token invalid' and kept retrying with the same bad token. Fix this by creating a custom JwtAuthenticationFilter that catches JWT exceptions and delegates to the AuthenticationEntryPoint with a custom message. Or better: use Spring Security's OAuth2 resource server with a custom JwtDecoder that maps exceptions to specific error codes. I use a custom filter that sets a request attribute with the error code before calling the entry point.
When a JWT token is expired, the response includes a custom attribute 'jwt_error' with value 'TOKEN_EXPIRED'. The client can use this to trigger a token refresh flow.
🔥JWT Error Differentiation
📊 Production Insight
In our system, we also track JWT error types in metrics (Prometheus). A spike in 'TOKEN_INVALID' errors often indicates a token theft or misconfigured client.
🎯 Key Takeaway
For JWT auth, catch specific exceptions in a custom filter and delegate to AuthenticationEntryPoint with context-rich error codes.
● Production incidentPOST-MORTEMseverity: high
The Silent 403 That Cost Us $50,000 in Revenue
Symptom
Users reported 'premium features not working' but no error popups. Backend logs showed 403 responses with empty bodies. Client app had no error handling for empty 403 responses.
Assumption
We assumed the default AccessDeniedHandler behavior (redirect to /error) was fine because our @ControllerAdvice would catch it. Wrong — the default handler sends a response directly, bypassing @ControllerAdvice entirely.
Root cause
The default AccessDeniedHandler sends a 403 with an empty body. Our mobile client checked response code only — 403 wasn't in its error list, so it treated the response as success with no data.
Fix
Implemented a custom AccessDeniedHandler that returns a JSON body with an error code and message. Also updated the mobile client to handle all non-2xx responses.
Key lesson
Never assume default Spring Security exception handlers are safe for production APIs.
Always explicitly set custom handlers that return structured error responses.
Feature
Default Spring Security
Custom Implementation
401 Response
Redirect to /login (302)
JSON body with error code (401)
403 Response
Empty body (403)
JSON body with required role (403)
Error Details
HTML page or empty
Structured JSON with code, message, timestamp
Client Friendliness
Poor (browser-focused)
Excellent (API-focused)
Testability
Hard to assert HTML
Easy with MockMvc and JSON path
⚙ Quick Reference
8 commands from this guide
File
Command / Code
Purpose
pom.xml
Setting Up the Project
DefaultSecurityConfig.java
@Configuration
What the Official Docs Won't Tell You
CustomAuthenticationEntryPoint.java
@Component
Custom AuthenticationEntryPoint for 401 Errors
CustomAccessDeniedHandler.java
@Component
Custom AccessDeniedHandler for 403 Errors
GlobalExceptionHandler.java
@ControllerAdvice
Global Exception Handling with @ControllerAdvice
SecurityConfig.java
@Configuration
Integrating Custom Handlers with SecurityConfig
SecurityExceptionHandlerTest.java
@SpringBootTest
Testing Exception Handling with MockMvc
JwtAuthenticationFilter.java
public class JwtAuthenticationFilter extends OncePerRequestFilter {
Advanced
Key takeaways
1
Custom AuthenticationEntryPoint and AccessDeniedHandler are mandatory for REST APIs. Default handlers return HTML redirects or empty bodies.
2
@ControllerAdvice only catches exceptions from controllers, not from the security filter chain. Use both mechanisms for complete coverage.
3
Always include error codes and correlation IDs in security exception responses for debugging and client-side handling.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01JUNIOR
Explain the difference between AuthenticationEntryPoint and AccessDenied...
Q02JUNIOR
How would you handle JWT token expiration in a Spring Security REST API?
Q03JUNIOR
What happens if you don't configure any exception handling in Spring Sec...
Q01 of 03JUNIOR
Explain the difference between AuthenticationEntryPoint and AccessDeniedHandler in Spring Security.
ANSWER
AuthenticationEntryPoint is triggered when an unauthenticated user tries to access a secured resource. It's called when an AuthenticationException is thrown (e.g., missing or invalid token). AccessDeniedHandler is triggered when an authenticated user tries to access a resource they don't have permission for (e.g., a USER role trying to access an ADMIN endpoint). Both are configured via http.exceptionHandling() and should return appropriate HTTP responses (401 for AuthenticationEntryPoint, 403 for AccessDeniedHandler).
Q02 of 03JUNIOR
How would you handle JWT token expiration in a Spring Security REST API?
ANSWER
I would create a custom OncePerRequestFilter that extracts the JWT from the Authorization header, validates it, and catches specific exceptions like ExpiredJwtException. For expired tokens, I delegate to the AuthenticationEntryPoint with a custom error code like 'TOKEN_EXPIRED'. The response includes this code so the client can trigger a token refresh flow. I also log the expiration event for monitoring. The filter should be added to the security filter chain before UsernamePasswordAuthenticationFilter.
Q03 of 03JUNIOR
What happens if you don't configure any exception handling in Spring Security?
ANSWER
Spring Security uses default implementations: LoginUrlAuthenticationEntryPoint for unauthenticated requests (redirects to /login), and AccessDeniedHandlerImpl for unauthorized requests (forwards to /error). For REST APIs, this is problematic because clients expect JSON responses, not HTML redirects or error pages. It also makes debugging difficult because the error responses lack structure. Always configure custom handlers for production APIs.
01
Explain the difference between AuthenticationEntryPoint and AccessDeniedHandler in Spring Security.
JUNIOR
02
How would you handle JWT token expiration in a Spring Security REST API?
JUNIOR
03
What happens if you don't configure any exception handling in Spring Security?
JUNIOR
FAQ · 3 QUESTIONS
Frequently Asked Questions
01
Why is my @ControllerAdvice not catching security exceptions?
Because Spring Security's filter chain executes before your controllers. Exceptions thrown in the filter chain (like AuthenticationException and AccessDeniedException) are caught by ExceptionTranslationFilter, which delegates to AuthenticationEntryPoint and AccessDeniedHandler respectively. @ControllerAdvice only handles exceptions thrown by your controller methods. You need both: custom security handlers for filter-level exceptions and @ControllerAdvice for controller-level exceptions.
Was this helpful?
02
How do I return different HTTP status codes for different security exceptions?
Use the exception type to determine the status code. For AuthenticationException (invalid credentials, expired token), return 401. For AccessDeniedException (insufficient permissions), return 403. For other exceptions like BadCredentialsException, you might want 401 as well. In your custom AuthenticationEntryPoint, you can check the exception type using instanceof and set different status codes accordingly. Always log the full exception server-side.
Was this helpful?
03
Can I use @ExceptionHandler inside a @RestController for security exceptions?
No. @ExceptionHandler only works for exceptions thrown within the same controller. Security exceptions are thrown by the filter chain before the controller is reached. You must use AuthenticationEntryPoint and AccessDeniedHandler at the security configuration level. However, if you have a custom filter that catches security exceptions and re-throws them as runtime exceptions, your @ControllerAdvice could catch them — but this is a bad practice because it bypasses Spring Security's intended flow.