Spring Security Manual Authentication: A Senior Dev's Guide
Learn how to implement manual authentication in Spring Security 6.2 with real production insights, debugging tips, and code examples.
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
- ✓Basic knowledge of Spring Boot and Spring Security
- ✓Familiarity with dependency injection and configuration
- ✓Understanding of HTTP and REST APIs
- Use
AuthenticationManagerandUsernamePasswordAuthenticationTokenfor manual login. - Always store
SecurityContextin aSecurityContextHolderstrategy. - Validate credentials against your user service before creating the token.
- Handle exceptions like
BadCredentialsExceptionandLockedExceptiongracefully. - Never expose authentication logic in controllers; use a dedicated service.
Think of Spring Security as a bouncer at a club. Automatic authentication is like having a guest list that the bouncer checks automatically. Manual authentication is when you walk up to the bouncer, show your ID yourself, and he decides to let you in after verifying it. You control the process step by step.
You're building a SaaS billing system. Users log in via a custom form, but you need to validate credentials against a legacy CRM and then issue a JWT. Spring Security's auto-config doesn't cut it—you need manual control. This is where manual authentication shines.
Most tutorials show you how to use Spring Security's auto-configuration with a login form. That's fine for demos. In production, you'll need to authenticate users manually—especially when integrating with external systems, supporting multiple authentication sources, or building a REST API.
In this guide, I'll show you how to implement manual authentication in Spring Security 6.2. We'll cover the core components: AuthenticationManager, AuthenticationProvider, and SecurityContextHolder. You'll see real code, production pitfalls, and debugging techniques I've learned from years of building secure systems.
By the end, you'll be able to implement custom login flows that are secure, testable, and maintainable. No more fighting with auto-config.
Understanding the Authentication Architecture
Before writing code, you need to understand Spring Security's authentication architecture. It's a chain of components:
- AuthenticationFilter: Intercepts the request and extracts credentials.
- AuthenticationManager: The orchestrator that delegates to providers.
- AuthenticationProvider: Contains the actual logic to validate credentials.
- SecurityContextHolder: Stores the authenticated principal for the request.
In manual authentication, you bypass the filter chain and directly interact with AuthenticationManager. This gives you full control.
Here's the flow: 1. You create an Authentication object (usually UsernamePasswordAuthenticationToken). 2. You call AuthenticationManager.authenticate(token). 3. The manager delegates to a matching AuthenticationProvider. 4. If successful, you get back a fully populated Authentication object. 5. You set it in SecurityContextHolder.
This pattern is used in REST APIs where you authenticate once and return a token (JWT) that the client sends on subsequent requests.
I've seen teams overcomplicate this by writing their own authentication logic from scratch. Don't. Spring Security's AuthenticationManager is well-tested and handles edge cases you'll miss.
authenticationManager.authenticate() from a controller. Wrap it in a service that can handle exceptions and logging consistently.AuthenticationManager directly, giving you complete control over the login flow.Configuring AuthenticationManager Bean
To use manual authentication, you need to provide an AuthenticationManager bean. In Spring Security 6.2, the common approach is to define an AuthenticationConfiguration and expose the manager.
Here's the configuration:
```java @Configuration @EnableWebSecurity public class SecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authz -> authz .requestMatchers("/api/auth/**").permitAll() .anyRequest().authenticated() ) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .csrf(csrf -> csrf.disable()); return http.build(); }
@Bean public AuthenticationManager authenticationManager( AuthenticationConfiguration authConfig) throws Exception { return authConfig.getAuthenticationManager(); } } ```
I prefer this over using AuthenticationManagerBuilder because it's cleaner and leverages the auto-configuration.
One gotcha: if you have multiple AuthenticationProvider beans, they are automatically picked up by the AuthenticationManager. But if you need a specific order, use @Order or manually build the manager with ProviderManager.
What the docs don't tell you: If you declare your own AuthenticationManager bean, Spring Boot's auto-configuration will back off. You'll lose the default providers. Always ensure you're not accidentally overriding the bean.
AuthenticationProvider wasn't invoked. The culprit: they had declared an AuthenticationManager bean that used AuthenticationManagerBuilder without adding the custom provider. Spring Boot's auto-configuration backed off, and the provider was never registered.AuthenticationManager as a bean using AuthenticationConfiguration to keep your configuration clean.Implementing a Custom AuthenticationProvider
The AuthenticationProvider is where you put your custom authentication logic. For example, validating credentials against a legacy CRM or an LDAP server.
Here's a custom provider that checks credentials against a database:
```java @Component public class DatabaseAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService; private final PasswordEncoder passwordEncoder;
public DatabaseAuthenticationProvider(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) { this.userDetailsService = userDetailsService; this.passwordEncoder = passwordEncoder; }
@Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String username = authentication.getName(); String password = authentication.getCredentials().toString();
UserDetails user = userDetailsService.loadUserByUsername(username);
if (!passwordEncoder.matches(password, user.getPassword())) { throw new BadCredentialsException("Bad credentials"); }
if (!user.isEnabled()) { throw new DisabledException("User disabled"); }
return new UsernamePasswordAuthenticationToken( user, password, user.getAuthorities()); }
@Override public boolean supports(Class<?> authentication) { return UsernamePasswordAuthenticationToken.class .isAssignableFrom(authentication); } } ```
Notice the method. It tells Spring Security which supports()Authentication type this provider handles. If you forget it, your provider won't be called.
Production insight: Always check for isEnabled(), isAccountNonLocked(), etc. I've debugged cases where a locked account was still able to authenticate because the provider only checked the password.
AuthenticationProvider gives you full control over credential validation, including account status checks.Handling Authentication Exceptions
Authentication exceptions like BadCredentialsException and LockedException need to be handled properly. In a REST API, you typically return a 401 with a JSON error body.
You can customize the response using a custom AuthenticationEntryPoint:
```java @Component public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setContentType("application/json;charset=UTF-8"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); String errorMessage = "Authentication failed: " + authException.getMessage(); response.getWriter().write("{\"error\":\"" + errorMessage + "\"}"); } } ```
Then register it in your security config:
``java .exceptionHandling(exceptions -> exceptions .authenticationEntryPoint(customAuthenticationEntryPoint) ) ``
What the official docs won't tell you: The AuthenticationEntryPoint is only invoked for authentication exceptions. For access denied (authorization) exceptions, you need a separate AccessDeniedHandler. Many developers confuse the two.
Also, if you're using JWT, you might want to return different error messages for expired tokens vs. invalid tokens. I've seen teams expose too much information in error messages, which can aid attackers. Keep error messages generic, but log the details server-side.
@ControllerAdvice for REST APIs. This centralizes error handling and allows you to return consistent JSON structures for both authentication and authorization errors.AuthenticationEntryPoint to control the HTTP response for authentication failures.What the Official Docs Won't Tell You
I've been using Spring Security for over a decade. Here are the hard-learned truths:
1. SecurityContextHolder Strategy is Global, Not Per-Request
Many developers assume the strategy (e.g., MODE_INHERITABLETHREADLOCAL) can be set per request. It can't. It's a JVM-wide setting. If you set it in one filter, it affects the entire application. The only safe way is to set it once at application startup.
2. AuthenticationManager Bean Override Pitfalls
If you define your own AuthenticationManager bean, Spring Boot's auto-configuration will not create the default one. This means you lose all default providers (like the one for in-memory users). Always explicitly add your providers if you override.
3. Exception Handling in Filters
The AuthenticationEntryPoint only handles exceptions thrown during authentication. If your filter chain is misconfigured, you might get exceptions that bypass the entry point. Always test with a misconfigured filter to see the actual response.
4. Stateless vs Stateful Session Management
If you're using manual authentication for a REST API, set session creation policy to STATELESS. Otherwise, Spring Security will create a session even if you don't need one. This can lead to unexpected behavior with CSRF and session fixation.
5. Thread Safety of SecurityContextHolder
By default, SecurityContextHolder uses ThreadLocal. If you spawn new threads, the security context is not propagated. Use MODE_INHERITABLETHREADLOCAL or wrap tasks with DelegatingSecurityContextRunnable. I've seen production outages because of this.
6. Testing Manual Authentication
Testing manual authentication requires mocking AuthenticationManager and verifying SecurityContextHolder. Use SecurityContextHolderTestExecutionListener in Spring Boot tests to clear the context after each test.
These are the gotchas that will save you hours of debugging.
SecurityContextHolder.setStrategyName inside a controller method, which only applied to the current thread. Async tasks ran with the default strategy and lost the context.Integrating with JWT Token Generation
In many projects, manual authentication is used to generate a JWT token that the client sends on subsequent requests. Here's how it fits together:
- User sends username/password to
/api/auth/login. - Your service calls
AuthenticationManager.authenticate(). - If successful, generate a JWT and return it.
- On subsequent requests, a filter extracts the JWT and sets the security context.
Here's a simplified login endpoint:
```java @RestController @RequestMapping("/api/auth") public class AuthController {
private final ManualAuthenticationService authService; private final JwtTokenProvider tokenProvider;
public AuthController(ManualAuthenticationService authService, JwtTokenProvider tokenProvider) { this.authService = authService; this.tokenProvider = tokenProvider; }
@PostMapping("/login") public ResponseEntity<JwtResponse> login(@RequestBody LoginRequest request) { Authentication authentication = authService.authenticate( request.getUsername(), request.getPassword()); String token = tokenProvider.generateToken(authentication); return ResponseEntity.ok(new JwtResponse(token)); } } ```
Production insight: Never store the Authentication object in the session for a JWT-based API. The client should send the token with each request. If you store the context in the session, you defeat the purpose of stateless authentication.
Also, ensure your JWT filter sets SecurityContextHolder with the token's authentication. Otherwise, the request will be unauthenticated.
SecurityContext after login, leading to authentication persisting across requests even without a valid token. This is a security vulnerability.The Midnight Auth Loop: How a Missing `SecurityContext` Caused a Production Outage
SecurityContext in the request thread would automatically propagate to child threads and async operations.SecurityContextHolder was configured with MODE_INHERITABLETHREADLOCAL in some places and MODE_THREADLOCAL in others. Async tasks lost the security context because they ran on different threads.SecurityContextHolder strategy to MODE_INHERITABLETHREADLOCAL and ensure all async executors use DelegatingSecurityContextAsyncTaskExecutor.- Always configure
SecurityContextHolderstrategy globally, not per-request. - Use
DelegatingSecurityContextRunnableorDelegatingSecurityContextExecutorfor async tasks. - Test authentication under concurrent load to catch thread propagation issues.
- Log security context presence in filters to debug propagation.
- Document the threading model for your authentication flow.
SecurityContext is stored in the session. Verify SecurityContextPersistenceFilter is configured. Look for missing SecurityContextHolder.setContext() after authentication.SecurityContextHolder strategy. Check if behind a proxy that strips authentication headers.BadCredentialsException thrown for valid credentialsUserDetailsService returns correct user. Look at AuthenticationProvider.supports() method.curl -v -X POST /login -d 'username=admin&password=admin'check response headers for Set-CookieSecurityContextHolder.getContext().setAuthentication(authentication)| File | Command / Code | Purpose |
|---|---|---|
| ManualAuthenticationService.java | @Service | Understanding the Authentication Architecture |
| SecurityConfig.java | @Configuration | Configuring AuthenticationManager Bean |
| DatabaseAuthenticationProvider.java | @Component | Implementing a Custom AuthenticationProvider |
| CustomAuthenticationEntryPoint.java | @Component | Handling Authentication Exceptions |
| SecurityContextConfig.java | @Component | What the Official Docs Won't Tell You |
| AuthController.java | @RestController | Integrating with JWT Token Generation |
Key takeaways
SecurityContextHolder strategy globally and clear context in stateless APIs.AuthenticationProvider allows integration with any user store.AuthenticationEntryPoint for consistent error responses.Interview Questions on This Topic
Explain how to implement manual authentication in Spring Security.
AuthenticationManager and call authenticate() with a UsernamePasswordAuthenticationToken. Then set the result in SecurityContextHolder. Provide a custom AuthenticationProvider if needed.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
That's Spring Security. Mark it forged?
4 min read · try the examples if you haven't