Home Java Spring Security Custom AuthenticationFailureHandler: The Complete Guide
Intermediate 4 min · July 14, 2026

Spring Security Custom AuthenticationFailureHandler: The Complete Guide

Learn how to implement a custom AuthenticationFailureHandler in Spring Security 6.2.

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.

Follow
Production
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
Before you start⏱ 15-20 min read
  • Basic knowledge of Spring Boot and Spring Security
  • Familiarity with Java 17+ and lambda expressions
  • Understanding of HTTP status codes and REST API design
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Use AuthenticationFailureHandler to customize login failure responses
  • Implement SimpleUrlAuthenticationFailureHandler or write your own
  • Inject failureHandler into SecurityFilterChain configuration
  • Handle exceptions like BadCredentialsException, LockedException, etc.
  • For REST APIs, return JSON instead of redirecting
✦ Definition~90s read
What is Spring Security Custom AuthenticationFailureHandler Implementation?

A Spring Security AuthenticationFailureHandler is a strategy interface that lets you customize the response sent to the client when an authentication attempt fails.

Think of an AuthenticationFailureHandler like a bouncer at a club who doesn't just say 'no entry' but explains why: wrong password, account locked, etc.
Plain-English First

Think of an AuthenticationFailureHandler like a bouncer at a club who doesn't just say 'no entry' but explains why: wrong password, account locked, etc. It lets you customize the rejection message instead of just slamming the door.

I've lost count of how many times I've seen production apps where users get a generic 'Login failed' message after mistyping their password. That's not just bad UX — it's a security anti-pattern. Spring Security's default behavior redirects to a login error page, but in modern REST APIs, you need JSON responses, rate limiting hints, and account lockout feedback.

Enter the AuthenticationFailureHandler. It's the hook that lets you control exactly what happens when authentication fails. Whether you're building a traditional form-login app or a stateless JWT-based API, you need to handle failures gracefully.

In this guide, I'll show you how to implement a custom AuthenticationFailureHandler from scratch. We'll cover the common pitfalls I've seen in production — like forgetting to handle account lockouts or accidentally leaking sensitive information in error messages. You'll also get a production debugging guide and a real incident story that'll save you from a 2 AM pager call.

Let's dive in.

Why You Need a Custom AuthenticationFailureHandler

Spring Security's default failure handling is designed for traditional server-rendered form login. It redirects to /login?error and that's it. For modern apps—especially REST APIs—that's useless. You need to return structured JSON with appropriate HTTP status codes, maybe include rate-limiting headers, or even trigger account lockout notifications.

A custom AuthenticationFailureHandler gives you full control over the response. You can differentiate between bad credentials, expired passwords, locked accounts, or disabled accounts. Each can have a different response body and status code. For example, a locked account should return 423 Locked, not just 401.

Here's a concrete scenario: a SaaS billing system. If a user's account is disabled due to non-payment, you want to return a 402 Payment Required status. The default handler can't do that. With a custom handler, you can map exceptions to business-relevant responses.

Another use case: security auditing. You can log every authentication failure with the exact reason, IP address, and timestamp. The default handler doesn't log anything useful.

Bottom line: if you're building anything beyond a simple demo app, you need a custom AuthenticationFailureHandler.

CustomAuthenticationFailureHandler.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception)
            throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        String errorMessage = "Invalid username or password";
        if (exception.getMessage().contains("locked")) {
            response.setStatus(423);
            errorMessage = "Account locked due to multiple failed attempts";
        } else if (exception.getMessage().contains("disabled")) {
            response.setStatus(403);
            errorMessage = "Account disabled";
        }
        String json = String.format("{\"error\": \"%s\", \"status\": %d}",
                errorMessage, response.getStatus());
        response.getWriter().write(json);
    }
}
💡Don't Leak Information
📊 Production Insight
In production, always check the exception type hierarchy. BadCredentialsException, LockedException, DisabledException, AccountExpiredException, and CredentialsExpiredException are all subclasses of AuthenticationException. Handle each appropriately.
🎯 Key Takeaway
Custom handlers let you return business-relevant HTTP statuses and JSON bodies instead of a generic redirect.

Setting Up the Security Configuration

Once you have your handler, you need to wire it into Spring Security's filter chain. In your SecurityFilterChain bean, use the .failureHandler() method on the formLogin() or httpBasic() configuration.

A common mistake I see is developers forgetting to call .failureHandler() and wondering why their handler isn't invoked. Another pitfall: if you're using multiple authentication mechanisms (e.g., form login and JWT), you need to set the handler on each one separately.

Here's a complete configuration for a stateless REST API using form login only for authentication. Note that we disable CSRF and set the session creation policy to STATELESS.

SecurityConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .formLogin()
                .loginProcessingUrl("/login")
                .usernameParameter("username")
                .passwordParameter("password")
                .failureHandler(new CustomAuthenticationFailureHandler())
                .permitAll()
            .and()
            .authorizeRequests()
                .antMatchers("/login").permitAll()
                .anyRequest().authenticated();
        return http.build();
    }
}
⚠ CSRF and Stateless APIs
📊 Production Insight
In Spring Boot 3.x (Spring Security 6.x), the configuration has changed. You no longer use .and() chaining but lambda DSL. The example above uses the older style for clarity, but for new projects, use the lambda DSL to avoid deprecation warnings.
🎯 Key Takeaway
Wire your handler into the SecurityFilterChain using .failureHandler() on the form login configuration.

Handling Different Exception Types Gracefully

Not all authentication failures are the same. Spring Security throws different exceptions for different scenarios:

  • BadCredentialsException: wrong username/password
  • LockedException: account locked (e.g., too many attempts)
  • DisabledException: account disabled
  • AccountExpiredException: account expired
  • CredentialsExpiredException: password expired
  • InsufficientAuthenticationException: missing authentication (e.g., no token)

Your handler should differentiate these to provide appropriate feedback. For example, a locked account should return 423 Locked, an expired password should return 426 Upgrade Required, and a disabled account should return 403 Forbidden.

But be careful: returning specific error messages can be a security risk. Attackers can use them to enumerate valid usernames (e.g., 'User not found' vs 'Bad credentials'). The safest approach is to return a generic 'Invalid username or password' for all exceptions except account state issues that don't reveal user existence, like 'Account locked' (which implies the account exists but is locked).

Here's an enhanced handler that maps exceptions to appropriate status codes and messages.

EnhancedAuthenticationFailureHandler.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import org.springframework.security.authentication.*;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

public class EnhancedAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception)
            throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        Map<String, Object> body = new HashMap<>();
        body.put("timestamp", LocalDateTime.now().toString());
        body.put("path", request.getRequestURI());

        if (exception instanceof BadCredentialsException) {
            response.setStatus(401);
            body.put("error", "Invalid username or password");
            body.put("code", "AUTH-001");
        } else if (exception instanceof LockedException) {
            response.setStatus(423);
            body.put("error", "Account locked due to multiple failed attempts");
            body.put("code", "AUTH-002");
            body.put("retryAfter", "15 minutes");
        } else if (exception instanceof DisabledException) {
            response.setStatus(403);
            body.put("error", "Account disabled");
            body.put("code", "AUTH-003");
        } else if (exception instanceof AccountExpiredException) {
            response.setStatus(410);
            body.put("error", "Account expired");
            body.put("code", "AUTH-004");
        } else if (exception instanceof CredentialsExpiredException) {
            response.setStatus(426);
            body.put("error", "Password expired");
            body.put("code", "AUTH-005");
        } else {
            response.setStatus(401);
            body.put("error", "Authentication failed");
            body.put("code", "AUTH-000");
        }

        ObjectMapper mapper = new ObjectMapper();
        response.getWriter().write(mapper.writeValueAsString(body));
    }
}
🔥Using ObjectMapper
📊 Production Insight
In production, always include a unique error code (like AUTH-001) so the client can handle errors programmatically without parsing messages. Also, consider adding a 'retryAfter' field for lockout scenarios.
🎯 Key Takeaway
Map specific AuthenticationException subclasses to appropriate HTTP status codes and error codes for client-side handling.

What the Official Docs Won't Tell You

Spring Security's documentation is comprehensive but misses several critical gotchas that I've learned the hard way.

1. The Handler is Not Called for All Failures The AuthenticationFailureHandler only fires for authentication attempts that go through the form login filter. If you're using HTTP Basic, the handler is different (AuthenticationEntryPoint). If you're using a custom filter, you need to invoke the handler manually. I once spent hours debugging why my handler wasn't called for JWT token failures — it was because the exception was thrown in a different filter.

2. Exception Messages Are Localized Spring Security uses message resources for exception messages. If you're relying on exception.getMessage() to detect the reason, the message might be in a different language depending on the locale. Always check the exception type, not the message string.

3. The Handler Can Be Called Multiple Times If you have multiple authentication mechanisms (e.g., form login and remember-me), the handler can be called more than once. Ensure your handler is idempotent — writing to the response twice will cause an IllegalStateException.

4. Stateless Session Pitfall When you set session creation policy to STATELESS, Spring Security still creates a session for the authentication failure. This can be a performance issue under heavy load. To avoid this, use SecurityContextHolderFilter and ensure no session is created.

5. Exception Translation Filter There's an ExceptionTranslationFilter that catches AuthenticationException and delegates to the AuthenticationEntryPoint. If you have both a custom failure handler and a custom entry point, make sure they don't conflict. The failure handler is for form login; the entry point is for requests that require authentication.

6. Testing is Tricky Mocking HttpServletRequest and HttpServletResponse is painful. Use Spring's MockHttpServletRequest and MockHttpServletResponse from spring-test. Also, remember that the handler runs in the security filter chain, so you need to test the full security configuration, not just the handler in isolation.

⚠ Exception Type vs Message
📊 Production Insight
If you're using a custom authentication provider, ensure it throws the correct exception type. A common mistake is throwing a generic AuthenticationException instead of BadCredentialsException, which breaks the handler's logic.
🎯 Key Takeaway
The failure handler is only for form login. Other auth mechanisms need different handling. Check exception types, not messages.

Integrating with Spring Security's Exception Handling

Your custom AuthenticationFailureHandler is just one piece of the puzzle. Spring Security also has AuthenticationEntryPoint (for unauthenticated requests) and AccessDeniedHandler (for unauthorized requests). These three work together to provide a comprehensive error response strategy.

For a REST API, you typically want
  • AuthenticationFailureHandler: for failed login attempts
  • AuthenticationEntryPoint: for requests without credentials (returns 401)
  • AccessDeniedHandler: for authenticated users without required role (returns 403)

Here's how to configure all three in a single SecurityFilterChain.

FullSecurityConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Configuration
@EnableWebSecurity
public class FullSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .exceptionHandling()
                .authenticationEntryPoint((request, response, authException) -> {
                    response.setContentType("application/json;charset=UTF-8");
                    response.setStatus(401);
                    response.getWriter().write("{\"error\":\"Unauthorized\"}");
                })
                .accessDeniedHandler((request, response, accessDeniedException) -> {
                    response.setContentType("application/json;charset=UTF-8");
                    response.setStatus(403);
                    response.getWriter().write("{\"error\":\"Forbidden\"}");
                })
            .and()
            .formLogin()
                .loginProcessingUrl("/login")
                .failureHandler(new CustomAuthenticationFailureHandler())
                .permitAll()
            .and()
            .authorizeRequests()
                .antMatchers("/login").permitAll()
                .anyRequest().authenticated();
        return http.build();
    }
}
💡Keep Handlers Separate
📊 Production Insight
In Spring Security 6, the lambda DSL is the recommended way. The example above uses the older style for readability, but for new projects, use lambdas to avoid deprecation.
🎯 Key Takeaway
For a complete error handling strategy, implement AuthenticationEntryPoint and AccessDeniedHandler alongside your failure handler.

Testing Your Custom AuthenticationFailureHandler

Testing authentication failure handling is often overlooked. Developers test the happy path (successful login) but ignore failure scenarios. Here's how to test your custom handler using Spring Boot's test utilities.

You'll need to mock the authentication attempt and verify the response. Use MockMvc to simulate a login request with invalid credentials.

AuthenticationFailureHandlerTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@SpringBootTest
@AutoConfigureMockMvc
public class AuthenticationFailureHandlerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void testBadCredentialsReturns401() throws Exception {
        mockMvc.perform(post("/login")
                .param("username", "invalid")
                .param("password", "invalid"))
                .andExpect(status().isUnauthorized())
                .andExpect(jsonPath("$.error").value("Invalid username or password"))
                .andExpect(jsonPath("$.code").value("AUTH-001"));
    }

    @Test
    public void testLockedAccountReturns423() throws Exception {
        // Assume there's a user with locked account in test data
        mockMvc.perform(post("/login")
                .param("username", "lockedUser")
                .param("password", "password"))
                .andExpect(status().isLocked())
                .andExpect(jsonPath("$.error").value("Account locked due to multiple failed attempts"));
    }

    @Test
    public void testDisabledAccountReturns403() throws Exception {
        mockMvc.perform(post("/login")
                .param("username", "disabledUser")
                .param("password", "password"))
                .andExpect(status().isForbidden())
                .andExpect(jsonPath("$.error").value("Account disabled"));
    }
}
🔥Test Data Setup
📊 Production Insight
In production, also test with invalid request formats (missing parameters, wrong content type). The handler should gracefully handle these cases without throwing exceptions.
🎯 Key Takeaway
Test all failure scenarios: bad credentials, locked, disabled, expired. Use MockMvc to verify status codes and JSON responses.
● Production incidentPOST-MORTEMseverity: high

The 500 Error That Locked Everyone Out

Symptom
Users reported 'Internal Server Error' on login failure. No error page, no JSON — just a blank 500.
Assumption
The developer assumed Spring Security's default failure handler would work fine for their REST API.
Root cause
The default SimpleUrlAuthenticationFailureHandler tries to redirect to /login?error, but the app had no such endpoint, causing an infinite redirect loop that threw an exception.
Fix
Implemented a custom AuthenticationFailureHandler that returns a 401 JSON response with a clear error message.
Key lesson
  • Never rely on default handlers for REST APIs — they assume form-login redirects.
  • Always test failure scenarios, not just happy paths.
  • Use custom handlers to return appropriate HTTP status codes (401 vs 403).
  • Include error codes for client-side handling, not just messages.
  • Log authentication failures with enough context to debug but without exposing secrets.
Production debug guideSymptom to Action5 entries
Symptom · 01
Login failure returns 302 redirect instead of JSON
Fix
Your custom handler is not being applied. Check SecurityFilterChain configuration — ensure .failureHandler(yourHandler) is set on the form login or authentication entry point.
Symptom · 02
Custom handler is never called
Fix
Verify that the authentication provider is actually throwing an exception. If using a custom provider, ensure it throws AuthenticationException on failure.
Symptom · 03
Error message exposes sensitive info (e.g., 'User not found')
Fix
Your handler is using the exception message directly. Wrap it in a generic message and log the details server-side.
Symptom · 04
Account locked message not showing
Fix
Check that your UserDetailsService returns a non-expired, non-locked account. The LockedException is only thrown if the account is actually locked in the UserDetails.
Symptom · 05
Too many redirects on failure
Fix
Your handler is probably calling sendRedirect to a page that doesn't exist, causing Spring Security to redirect again. Use forward or write directly to the response.
★ Quick Debug Cheat SheetCommon auth failure issues and immediate fixes.
Custom handler not invoked
Immediate action
Check SecurityFilterChain: .failureHandler(yourHandler) must be set.
Commands
curl -v -X POST http://localhost:8080/login -d 'username=test&password=wrong'
Check logs for 'AuthenticationFailureHandler'
Fix now
Add .failureHandler(yourHandler) to http.formLogin() in SecurityFilterChain.
JSON response not returned+
Immediate action
Ensure handler writes to HttpServletResponse directly, not redirect.
Commands
Check response headers: Content-Type should be application/json
Verify handler's onAuthenticationFailure method writes JSON
Fix now
Use response.setContentType('application/json') and response.getWriter().write(json).
Sensitive info leaked in error message+
Immediate action
Wrap exception message in a generic response.
Commands
Check exception type: BadCredentialsException vs UsernameNotFoundException
Log full exception stack trace server-side
Fix now
Return 'Invalid username or password' regardless of exception type.
FeatureDefault HandlerCustom Handler
Response typeRedirect to /login?errorAny (JSON, XML, etc.)
HTTP status code302 FoundConfigurable (401, 423, etc.)
Error differentiationNoneBy exception type
LoggingNoneCustomizable
SecurityMay leak infoControlled messages
⚙ Quick Reference
5 commands from this guide
FileCommand / CodePurpose
CustomAuthenticationFailureHandler.javapublic class CustomAuthenticationFailureHandler implements AuthenticationFailure...Why You Need a Custom AuthenticationFailureHandler
SecurityConfig.java@ConfigurationSetting Up the Security Configuration
EnhancedAuthenticationFailureHandler.javapublic class EnhancedAuthenticationFailureHandler implements AuthenticationFailu...Handling Different Exception Types Gracefully
FullSecurityConfig.java@ConfigurationIntegrating with Spring Security's Exception Handling
AuthenticationFailureHandlerTest.java@SpringBootTestTesting Your Custom AuthenticationFailureHandler

Key takeaways

1
Implement AuthenticationFailureHandler to customize login failure responses for REST APIs.
2
Map specific AuthenticationException subclasses to appropriate HTTP status codes.
3
Never expose sensitive information in error messages; use generic messages and log details.
4
Wire the handler into SecurityFilterChain using .failureHandler() on formLogin().
5
Test all failure scenarios with MockMvc to ensure correct behavior.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How would you implement a custom AuthenticationFailureHandler that retur...
Q02SENIOR
What security concerns should you consider when designing error messages...
Q03SENIOR
How would you test a custom AuthenticationFailureHandler in a Spring Boo...
Q01 of 03SENIOR

How would you implement a custom AuthenticationFailureHandler that returns different HTTP status codes for different exceptions?

ANSWER
Implement AuthenticationFailureHandler, check the exception type using instanceof (e.g., BadCredentialsException, LockedException), set the response status accordingly, and write a JSON body with error details.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
What's the difference between AuthenticationFailureHandler and AuthenticationEntryPoint?
02
How do I redirect to a custom error page instead of returning JSON?
03
Can I use the same handler for both form login and OAuth2 login?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.

Follow
Verified
production tested
July 15, 2026
last updated
2,398
articles · all by Naren
🔥

That's Spring Security. Mark it forged?

4 min read · try the examples if you haven't

Previous
How to Manually Authenticate a User in Spring Security
14 / 31 · Spring Security
Next
Securing a Spring Boot API With API Key and Secret Authentication