Spring Security is the de facto standard for securing Java applications, offering robust support for OAuth2, LDAP, and standard form logins. But what happens when “standard” isn’t enough?
You might face a scenario where user credentials live in a legacy mainframe, a proprietary API, or a multi-tenant database with complex sharding rules. In these cases, the default DaoAuthenticationProvider fails. Trying to hack standard filters to fit these requirements leads to brittle code, security vulnerabilities, and maintenance nightmares.
The solution lies in Spring Security’s modular architecture. By implementing a Custom Authentication Provider, you can inject your specific business logic directly into the security lifecycle without compromising the framework’s integrity.
This guide was rebuilt for Spring Boot 4.1 and Spring Security 7.1. Beyond the version bump, it fixes two things the original left out: the wiring shown below was already unnecessary boilerplate even under Security 6, and there was no answer for the question every real project eventually asks — what happens when part of your API needs your custom provider and another part needs JWT bearer tokens, in the same application?
| Part | Read this if… |
|---|---|
| 1 — Beginner | You’ve never written an AuthenticationProvider and want the smallest thing that works |
| 2 — Intermediate | It works, and you want to know exactly what wired it together — and how to debug it when it doesn’t |
| 3 — Advanced | You need this provider to coexist with JWT authentication, or you’ve been bitten by a silent wiring failure |
Verified against. Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring Security 7.1.1, GA 2026-06-09), Jakarta EE 11 / Servlet 6.1 (Tomcat 11+), Java 17 minimum with Java 25 (current LTS) recommended.
This revision does not ship a companion repository — every code sample below was compiled, not just read about, against the realspring-security-config,spring-security-core,spring-security-web,spring-security-oauth2-resource-serverandspring-security-oauth2-jose7.1.1 jars. Where a claim about framework internals is made, it is quoted from the actual 7.1.1 source rather than paraphrased from documentation — the sources are linked inline.
Understanding the Architecture
Before writing code, it is crucial to understand where your custom logic fits. Spring Security uses a delegation model.
- AuthenticationFilter: Intercepts the request.
- AuthenticationManager: The interface defining how authentication is processed.
- ProviderManager: The standard implementation that iterates through a list of providers.
- AuthenticationProvider: The component that actually validates the user.
Hold on to that list — Part 2 below exists entirely because step 2 and step 3 are wired together by more machinery than most tutorials admit, and Part 3 depends on knowing exactly how.
Part 1 — The Smallest Working Provider
When you create a custom provider, you are adding a new “voter” to the ProviderManager.
How a provider signals its verdict. If your custom provider returnsnullfromauthenticate(), the manager tries the next provider in the chain. If it throws an exception, authentication fails immediately — there is no partial credit.
Step 1: Project Setup and Dependencies
Start from a Spring Boot 4.1 application. Maven, latest LTS Java.
<properties>
<java.version>21</java.version>
<spring-boot.version>4.1.1</spring-boot.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Java 21, not 17. Framework 7 kept the Java 17 floor, but Boot 4.1 supports up to Java 26 and the Spring team’s own guidance is to run the latest LTS. Everything in this article was compiled against JDK 25.
Step 2: Implementing the Custom Authentication Provider
The heart of this implementation is the AuthenticationProvider interface. You override two methods:
authenticate: Contains the logic to verify credentials.supports: Tells Spring Security which token type this provider can handle (usuallyUsernamePasswordAuthenticationToken).
Create CustomAuthProvider.java. This version is deliberately naive — Part 3 replaces the credential check with a real PasswordEncoder once you understand why the naive version is a liability.
package com.ankurm.security.auth;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import java.util.ArrayList;
import java.util.List;
public class CustomAuthProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 1. Extract credentials from the authentication object
String username = authentication.getName();
String password = authentication.getCredentials().toString();
// 2. VALIDATION LOGIC
// In a real app, inject a Service or Repository here to check DB/External API
if ("admin".equals(username) && "secret".equals(password)) {
return createSuccessfulAuth(username, password, "ROLE_ADMIN", "ROLE_USER");
} else if ("user".equals(username) && "password".equals(password)) {
return createSuccessfulAuth(username, password, "ROLE_USER");
}
// 3. Fail explicitly if credentials don't match
throw new BadCredentialsException("Invalid credentials provided");
}
// Helper method to construct the authenticated token
private Authentication createSuccessfulAuth(String username, String password, String... roles) {
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
for (String role : roles) {
authorities.add(new SimpleGrantedAuthority(role));
}
// IMPORTANT: The third argument (authorities) marks the token as authenticated
return new UsernamePasswordAuthenticationToken(username, password, authorities);
}
@Override
public boolean supports(Class<?> authenticationType) {
// Ensure this provider only processes standard username/password tokens
return authenticationType.equals(UsernamePasswordAuthenticationToken.class);
}
}
Step 3: Wiring the Configuration
The 2025 version of this article manually built a ProviderManager and exposed it as an AuthenticationManager bean. Delete that bean. It was never necessary — not under Security 6, and not now. Part 2 shows exactly why; for the moment, the whole configuration is this:
package com.ankurm.security.config;
import com.ankurm.security.auth.CustomAuthProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public CustomAuthProvider customAuthProvider() {
return new CustomAuthProvider();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// Attach the provider directly to this chain
.authenticationProvider(customAuthProvider())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
// Spring Security's built-in generated login page -- no /login
// controller or template required to get this running
.formLogin(Customizer.withDefaults())
.logout(Customizer.withDefaults());
return http.build();
}
}
Note on the original example. The 2025 version pointed.loginPage("/login")at a controller that was never shown, which 404s on a fresh checkout.Customizer.withDefaults()uses Spring Security’s own generated login page instead — correct out of the box, and a custom page is a one-line swap once you have a template to point it at.
Step 4: Verification and Testing
A REST controller with endpoints protected by different roles:
package com.ankurm.security.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TestController {
@GetMapping("/public/status")
public String publicEndpoint() {
return "Public access - System Healthy";
}
@GetMapping("/user/dashboard")
public String userDashboard() {
return "User content - Visible to USER and ADMIN";
}
@GetMapping("/admin/panel")
public String adminPanel() {
return "Admin Content - RESTRICTED";
}
}
Testing Scenarios Matrix
Use curl or Postman to verify these results.
| Endpoint | Credentials Used | Expected HTTP Status | Explanation |
/public/status | None | 200 OK | Publicly accessible. |
/user/dashboard | user / password | 200 OK | Authenticated as User. |
/admin/panel | user / password | 403 Forbidden | Valid login, but insufficient role. |
/admin/panel | admin / secret | 200 OK | Authenticated as Admin. |
/profile | invalid / invalid | 401 Unauthorized | Authentication failed. |
Part 2 — What Actually Wires This Together
Most “Security 6 to 7” upgrade posts will tell you AuthenticationManager wiring changed. It mostly didn’t — the mechanism below has existed since Spring Security 4.1. What changed is that the manual ProviderManager pattern went from “unnecessary” to “actively works against you,” because it disables that mechanism. Here is what it actually does.
What .authenticationProvider(customAuthProvider()) actually touches
Every SecurityFilterChain bean method receives its own HttpSecurity instance — it is declared @Scope("prototype") in Spring Security’s own configuration, so each chain gets a fresh one. That instance carries a local, empty AuthenticationManagerBuilder, and its parent is set to one shared, application-wide AuthenticationManager:
// org.springframework.security.config.annotation.web.configuration.HttpSecurityConfiguration
AuthenticationManagerBuilder authenticationBuilder =
new DefaultPasswordEncoderAuthenticationManagerBuilder(this.objectPostProcessor, passwordEncoder);
authenticationBuilder.parentAuthenticationManager(authenticationManager());
HttpSecurity http = new HttpSecurity(this.objectPostProcessor, authenticationBuilder, createSharedObjects());
Calling .authenticationProvider(...) registers the provider on that local builder, scoped to this one chain. If you skip it, the local builder stays empty and every authentication attempt falls through to the shared parent instead.
Where the shared parent gets its provider
That parent is populated lazily, by a configurer whose entire job is to look for exactly one AuthenticationProvider bean and wire it in automatically. Quoted directly from the 7.1.1 source, because the actual condition is easy to get wrong by memory:
// org.springframework.security.config.annotation.authentication.configuration
// .InitializeAuthenticationProviderBeanManagerConfigurer (since 4.1)
if (auth.isConfigured()) {
return;
}
String[] beanNames = context.getBeanNamesForType(AuthenticationProvider.class);
if (beanNames.length == 0) {
return;
}
else if (beanNames.length > 1) {
// logs an INFO message and returns -- no exception, no wiring
return;
}
AuthenticationProvider authenticationProvider =
context.getBean(beanNames[0], AuthenticationProvider.class);
auth.authenticationProvider(authenticationProvider);
This is why the original manual ProviderManager bean never bought anything: with a single CustomAuthProvider bean in the context, Spring wires it into the shared manager for free. Declaring your own AuthenticationManager bean, as the old article did, sets auth.isConfigured() to true and short-circuits this whole path — harmless when you have exactly one provider, but it is the reason the failure mode below is silent rather than loud.
The failure that produces no error. Add a secondAuthenticationProviderbean anywhere in the context — a second custom provider, a test fixture, a library you pulled in — andbeanNames.length > 1trips. Auto-registration backs off for both, logs one INFO line, and neither provider reaches the shared manager. Any chain relying on the bean auto-wire now throwsProviderNotFoundExceptionat request time, with nothing in the startup logs above INFO to explain why. The fix is the one this article uses throughout: attach providers explicitly with.authenticationProvider(...)on the specific chain instead of leaving it to bean discovery.
Debugging: which provider actually ran
ProviderManager accepts an AuthenticationEventPublisher — wire DefaultAuthenticationEventPublisher (Spring Security ships it; HttpSecurityConfiguration falls back to it automatically if you don’t supply one) and listen for AuthenticationSuccessEvent / AbstractAuthenticationFailureEvent. The event carries the Authentication object, and its class plus authorities tell you which provider produced it — far faster than stepping through ProviderManager.authenticate() in a debugger, which is a loop over an interface with no provider names attached.
Reading the failure, not just the status code
Be specific with exceptions from authenticate() — the type controls the HTTP response Spring’s default failure handler produces:
BadCredentialsException— wrong username/password. The standard 401 case.LockedException/DisabledException— the account exists but shouldn’t authenticate right now. Distinct from bad credentials, and worth distinct client-facing messages.AuthenticationServiceException— the provider itself failed (a downstream API is down, a database timed out). This is a system error, not a credentials error, and conflating the two tells users to retry a password that was never wrong.
External Resource: for the full hierarchy, see the official Spring Security Authentication Architecture documentation.
Part 3 — Advanced: Passwords, and Living Alongside JWT
Fixing What Part 1 Left Naive
Never compare plain text passwords in production logic, the way the Part 1 provider does (password.equals("secret")). Inject a PasswordEncoder and compare hashes:
package com.ankurm.security.auth;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.List;
import java.util.Map;
public class EncodedCustomAuthProvider implements AuthenticationProvider {
private final PasswordEncoder passwordEncoder;
// Stand-in for a real lookup (DB, legacy API, ...). Passwords are BCrypt
// hashes, never plain text -- this is what Part 1's equals() check owed you.
private final Map<String, String> encodedPasswordsByUsername;
public EncodedCustomAuthProvider(PasswordEncoder passwordEncoder,
Map<String, String> encodedPasswordsByUsername) {
this.passwordEncoder = passwordEncoder;
this.encodedPasswordsByUsername = encodedPasswordsByUsername;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String rawPassword = authentication.getCredentials().toString();
String encoded = encodedPasswordsByUsername.get(username);
if (encoded == null || !passwordEncoder.matches(rawPassword, encoded)) {
throw new BadCredentialsException("Invalid credentials provided");
}
return new UsernamePasswordAuthenticationToken(username, null,
List.of(new SimpleGrantedAuthority("ROLE_USER")));
}
@Override
public boolean supports(Class<?> authenticationType) {
return authenticationType.equals(UsernamePasswordAuthenticationToken.class);
}
}
Erasing credentials. Spring Security erases the password from theAuthenticationobject after a successful login, to keep it out of heap dumps. If your provider needs the raw password for anything afterauthenticate()returns, it will already be null — finish all credential handling inside the method itself; the code above sets the returned token’s credentials tonulldeliberately rather than relying on the framework to erase it later.
Living Alongside JWT
The question the 2025 article never answered: what if /api/** needs to accept JWT bearer tokens issued by an authorization server, while everything else — an admin console, say — still authenticates through CustomAuthProvider? Add spring-boot-starter-oauth2-resource-server to the dependencies from Step 1, then two SecurityFilterChain beans, ordered and scoped by securityMatcher():
package com.ankurm.security.config;
import com.ankurm.security.auth.CustomAuthProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class MultiChainSecurityConfig {
@Bean
public CustomAuthProvider customAuthProvider() {
return new CustomAuthProvider();
}
// Chain 1: JWT-secured API. The bearer-token filter is authenticated by a
// JwtAuthenticationProvider that oauth2ResourceServer() builds internally --
// it never touches the shared AuthenticationManager, so it is unaffected by
// whatever AuthenticationProvider beans exist elsewhere in the context.
@Bean
@Order(1)
public SecurityFilterChain apiFilterChain(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.decoder(jwtDecoder)))
.csrf(csrf -> csrf.disable());
return http.build();
}
// Chain 2: everything else, authenticated by CustomAuthProvider via form login.
// Attaching the provider explicitly -- rather than counting on it being the
// only AuthenticationProvider bean in the context -- keeps it scoped to this
// chain even if a second provider bean shows up later (see Part 2).
@Bean
@Order(2)
public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
http
.authenticationProvider(customAuthProvider())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults());
return http.build();
}
}
JwtDecoder is autowired rather than built here — set spring.security.oauth2.resourceserver.jwt.issuer-uri (or jwk-set-uri) and Boot supplies it; the linked guide below covers building one by hand when there’s no discovery endpoint to point at.
The left side is why bearer tokens and form-login credentials don’t collide by accident. The right side is why they can collide the moment you add anything other than JWT to the API chain — enable httpBasic() on apiFilterChain “just for testing” and it authenticates against the shared manager, which means it authenticates against CustomAuthProvider, which means admin/secret now works against your JWT-only API.
The JWT side has its own dense set of gotchas — token validation defaults, the bearer-scheme exception classes living in three different packages in 7.1.1, and an RFC 9728 metadata filter that appears in the chain without you asking for it. That is a full article on its own: Spring Security 7.1 JWT Authentication: The Complete Guide covers it end to end, including a runnable companion repository.
The Long Tail
- Method security alongside
SecurityFilterChainrules.@PreAuthorizenow runs throughAuthorizationManager, not the removedAccessDecisionManager— if you have pre-7.0 custom voters, they need to be ported. Covered end to end, with runnable code, in Method Security in Spring Security 7: @PreAuthorize, @PostAuthorize and the Proxy Traps — including the three ways an annotated method runs without the check firing at all. For the broader breaking-change list, see the Spring Framework 6 to 7 Migration Guide. - Hand-built
RequestMatchers. This article only ever passes strings to.requestMatchers(...), which Boot resolves through an auto-configuredPathPatternRequestMatcher.Builderwithout any code change. If you were constructingAntPathRequestMatcherorMvcRequestMatcherinstances directly, those are what 7.0 actually removed. - Multiple custom providers, one chain. Attach each with its own
.authenticationProvider(...)call rather than adding a second bean and hoping — Part 2 explains exactly why the second bean goes quiet.
Comparison: Custom vs. Built-in Providers
When should you actually exert the effort to build what we just built?
| Feature | Built-in (DaoAuthenticationProvider) | Custom AuthenticationProvider |
| Data Source | JDBC, JPA, LDAP | Any (API, Legacy DB, File, RPC) |
| Complexity | Low (Configuration only) | Medium (Requires coding) |
| Flexibility | Limited to standard schemas | Infinite |
| Maintenance | Handled by Spring Team | Handled by You |
Should you even build one? If your credentials live in somethingUserDetailsServicecan already describe — a different table, a different column mapping — overrideUserDetailsServiceand keepDaoAuthenticationProvider. Less code, same guarantees, and you inherit every future Security patch for free. If you’re validating bearer tokens, use the resource server support, not a hand-rolled provider. Write a realAuthenticationProviderwhen the input genuinely isn’t a username/password pair, or the verification step needs logicUserDetailsServicehas no way to express — a legacy RPC call, a hardware-token check, a rule that depends on more than “does this hash match.”
Conclusion
Implementing a custom authentication provider in Spring Security empowers you to bridge modern security frameworks with legacy or complex user stores. By adhering to the AuthenticationProvider contract and correctly wiring the SecurityFilterChain, you maintain a clean separation of concerns while solving difficult business requirements.
Next Steps: now that you have a working provider with real password hashing, try running it alongside a JWT-secured chain using the pattern in Part 3, or connect your provider to an external REST API for validation.
Frequently Asked Questions
What is the difference between AuthenticationProvider and UserDetailsService?
UserDetailsService is a lighter interface used only to retrieve user data (username, password, authorities) from a database. The standard DaoAuthenticationProvider uses it. You implement AuthenticationProvider when you need to customize the actual verification logic (how passwords are checked) or if your user data isn’t in a database compatible with UserDetails.
Do I need to manually build a ProviderManager bean in Spring Security 7.1?
No — and this was already true in 6.x. Publish exactly one AuthenticationProvider bean and Spring wires it into the shared AuthenticationManager for you (a mechanism dating to Security 4.1). See Part 2 for what happens the moment you have two.
Can I have multiple Authentication Providers in Spring Security?
Yes. The ProviderManager iterates through a list of providers. You can configure multiple providers (e.g., one for LDAP and one for a custom DB). Spring will try them in order until one successfully authenticates the user or all fail — but as soon as you have more than one AuthenticationProvider bean, the automatic bean-discovery wiring in Part 2 stops working, so wire the list explicitly.
Can I mix a custom AuthenticationProvider with JWT authentication in the same application?
Yes — define two SecurityFilterChain beans, scope each with .securityMatcher(...), and give each chain @Order. See Part 3 for a working configuration and the one gotcha (leaking a second authentication mechanism onto the JWT chain) that catches people who skip the ordering.
How do I handle third-party API authentication failures in a custom provider?
If your custom provider calls an external API and that API is down, throw an AuthenticationServiceException. This signals a system error rather than a “Bad Credentials” error, allowing you to handle the failure gracefully (e.g., by showing a “Service Unavailable” page) instead of telling the user their password is wrong.
Further Reading
- Spring Security 7.1 JWT Authentication: The Complete Guide — the resource-server side of Part 3, with a runnable repository
- Method Security in Spring Security 7: @PreAuthorize, @PostAuthorize and the Proxy Traps — what happens to the
Authenticationthis article mints once a@PreAuthorizeexpression reads it, and the proxy traps that stop it being read at all - Spring Framework 6 to 7 Migration Guide — the full breaking-change list this article only touches where relevant
- Spring Security: Authentication Architecture (official reference)
- Spring Security: Migrating to 7.0 (official migration guide)
- RFC 9728 — OAuth 2.0 Protected Resource Metadata
No Comments yet!