Form Login Authentication: From Request to Session
Form Login: The Classic Web Authentication Flow
Form login is the traditional authentication mechanism for server-rendered web applications. The user fills in a username and password form, the server verifies credentials, creates a session, and returns a session cookie. All subsequent requests carry that cookie.
sequenceDiagram
participant Browser
participant SSF as Spring Security Filters
participant AM as AuthenticationManager
participant SS as HTTP Session Store
participant Controller
Browser->>SSF: GET /dashboard (unauthenticated)
SSF->>SS: Save original request (RequestCache)
SSF-->>Browser: 302 Redirect to /login
Browser->>SSF: GET /login
SSF-->>Browser: 200 HTML login page
Browser->>SSF: POST /login {username, password}
SSF->>AM: authenticate(UsernamePasswordToken)
AM-->>SSF: Authentication (success)
SSF->>SS: Store SecurityContext in session
SSF-->>Browser: 302 Redirect to /dashboard\nSet-Cookie: JSESSIONID=abc123
Browser->>SSF: GET /dashboard (JSESSIONID cookie)
SSF->>SS: Load SecurityContext from session
SSF->>Controller: Request with authenticated user
Controller-->>Browser: 200 Dashboard HTML
Minimal Configuration
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**", "/login").permitAll()
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults()); // uses Spring's built-in login page
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
// In-memory for demo — use DB-backed service in production
return new InMemoryUserDetailsManager(
User.withUsername("alice").password("{bcrypt}" + new BCryptPasswordEncoder().encode("secret"))
.roles("USER").build()
);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
With Customizer.withDefaults(), Spring Security:
- Generates a login page at
GET /login - Processes login at
POST /loginwith parametersusernameandpassword - Redirects to
/on success - Redirects to
/login?erroron failure - Provides a logout page at
GET /logoutand processes it atPOST /logout
Custom Login Page
Replace the generated page with your own:
http.formLogin(form -> form
.loginPage("/login") // your controller serves GET /login
.loginProcessingUrl("/auth/login") // form action — Spring processes POST here
.usernameParameter("email") // form field name (default: "username")
.passwordParameter("passwd") // form field name (default: "password")
.defaultSuccessUrl("/dashboard", true) // true = always redirect here
.failureUrl("/login?error=true")
);
@Controller
public class LoginController {
@GetMapping("/login")
public String loginPage(
@RequestParam(required = false) String error,
@RequestParam(required = false) String logout,
Model model
) {
if (error != null) {
model.addAttribute("errorMessage", "Invalid username or password.");
}
if (logout != null) {
model.addAttribute("logoutMessage", "You have been logged out.");
}
return "login"; // login.html Thymeleaf template
}
}
<!-- login.html (Thymeleaf) -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head><title>Login</title></head>
<body>
<h1>Sign In</h1>
<div th:if="${errorMessage}" style="color:red" th:text="${errorMessage}"></div>
<div th:if="${logoutMessage}" style="color:green" th:text="${logoutMessage}"></div>
<!-- Action must match loginProcessingUrl -->
<form action="/auth/login" method="post">
<!-- CSRF token is required for form submissions -->
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<label>Email: <input type="email" name="email" required/></label><br/>
<label>Password: <input type="password" name="passwd" required/></label><br/>
<button type="submit">Sign In</button>
</form>
</body>
</html>
Custom Success Handler
Control where the user goes after login — useful for role-based redirects:
@Component
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication
) throws IOException {
// Redirect based on role
String targetUrl = determineTargetUrl(authentication);
if (response.isCommitted()) {
return;
}
// Restore original request if user was redirected from a protected page
SavedRequest savedRequest = new HttpSessionRequestCache().getRequest(request, response);
if (savedRequest != null) {
redirectStrategy.sendRedirect(request, response, savedRequest.getRedirectUrl());
return;
}
redirectStrategy.sendRedirect(request, response, targetUrl);
}
private String determineTargetUrl(Authentication auth) {
if (auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))) {
return "/admin/dashboard";
}
if (auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_MANAGER"))) {
return "/manager/dashboard";
}
return "/dashboard";
}
}
http.formLogin(form -> form
.successHandler(customAuthenticationSuccessHandler)
);
Custom Failure Handler
Track failed login attempts (for account lockout) or return specific error messages:
@Component
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
private final LoginAttemptService loginAttemptService;
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override
public void onAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception
) throws IOException {
String username = request.getParameter("email");
loginAttemptService.recordFailedAttempt(username);
String errorParam;
if (exception instanceof BadCredentialsException) {
errorParam = "invalid";
} else if (exception instanceof DisabledException) {
errorParam = "disabled";
} else if (exception instanceof LockedException) {
errorParam = "locked";
} else {
errorParam = "error";
}
redirectStrategy.sendRedirect(request, response, "/login?error=" + errorParam);
}
}
http.formLogin(form -> form
.failureHandler(customAuthenticationFailureHandler)
);
Account Lockout After Failed Attempts
A complete login attempt tracking service:
@Service
public class LoginAttemptService {
private static final int MAX_ATTEMPTS = 5;
private static final Duration LOCKOUT_DURATION = Duration.ofMinutes(15);
private final Cache<String, Integer> attemptsCache = Caffeine.newBuilder()
.expireAfterWrite(LOCKOUT_DURATION)
.build();
public void recordFailedAttempt(String username) {
int attempts = getAttempts(username);
attemptsCache.put(username.toLowerCase(), attempts + 1);
}
public void resetAttempts(String username) {
attemptsCache.invalidate(username.toLowerCase());
}
public boolean isLocked(String username) {
return getAttempts(username) >= MAX_ATTEMPTS;
}
private int getAttempts(String username) {
Integer attempts = attemptsCache.getIfPresent(username.toLowerCase());
return attempts == null ? 0 : attempts;
}
}
// In UserDetailsService — check lock status at load time
@Override
public UserDetails loadUserByUsername(String username) {
User user = userRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("Not found"));
boolean locked = loginAttemptService.isLocked(username);
return org.springframework.security.core.userdetails.User.builder()
.username(user.getEmail())
.password(user.getPasswordHash())
.roles(user.getRoles().toArray(new String[0]))
.accountLocked(locked)
.build();
}
Logout Configuration
http.logout(logout -> logout
.logoutUrl("/auth/logout") // URL that triggers logout (default: /logout)
.logoutSuccessUrl("/login?logout") // redirect after logout
.invalidateHttpSession(true) // destroy session (default: true)
.clearAuthentication(true) // clear SecurityContext (default: true)
.deleteCookies("JSESSIONID", "remember-me") // delete cookies
.addLogoutHandler(customLogoutHandler) // optional: additional cleanup
);
Custom Logout Handler
@Component
public class TokenRevocationLogoutHandler implements LogoutHandler {
private final TokenBlacklistService tokenBlacklistService;
@Override
public void logout(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) {
if (authentication != null) {
// Revoke all active refresh tokens for this user
String username = authentication.getName();
tokenBlacklistService.revokeAllForUser(username);
}
}
}
Remember-Me Authentication
Remember-me keeps a user authenticated across browser restarts using a persistent cookie:
http
.formLogin(Customizer.withDefaults())
.rememberMe(remember -> remember
.key("unique-and-secret-remember-me-key") // signs the token
.tokenValiditySeconds(14 * 24 * 60 * 60) // 14 days
.rememberMeParameter("remember-me") // form checkbox name
.userDetailsService(userDetailsService)
);
<!-- Add checkbox to login form -->
<label>
<input type="checkbox" name="remember-me"/> Remember me for 14 days
</label>
Simple vs Persistent Remember-Me
Simple (token-based): The cookie encodes username + expiryTime + hash. No database needed. Vulnerable to theft — if the cookie is stolen, anyone can use it until expiry.
Persistent (database-backed): Stores a random series + token pair in the database. The cookie contains only the series identifier. On use, the token is rotated. If a stolen token is used, Spring detects the mismatch (old token used after rotation) and invalidates all sessions for that user.
// Persistent remember-me requires a PersistentTokenRepository
@Bean
public PersistentTokenRepository persistentTokenRepository(DataSource dataSource) {
JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
repo.setDataSource(dataSource);
repo.setCreateTableOnStartup(false); // create the table manually
return repo;
}
http.rememberMe(remember -> remember
.tokenRepository(persistentTokenRepository)
.tokenValiditySeconds(14 * 24 * 60 * 60)
.userDetailsService(userDetailsService)
);
-- Required table for persistent remember-me
CREATE TABLE persistent_logins (
username VARCHAR(64) NOT NULL,
series VARCHAR(64) NOT NULL PRIMARY KEY,
token VARCHAR(64) NOT NULL,
last_used TIMESTAMP NOT NULL
);
Always use persistent remember-me for production applications.
Complete Form Login Configuration
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final AppUserDetailsService userDetailsService;
private final CustomAuthenticationSuccessHandler successHandler;
private final CustomAuthenticationFailureHandler failureHandler;
private final TokenRevocationLogoutHandler logoutHandler;
private final PersistentTokenRepository persistentTokenRepository;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**", "/login", "/register").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.loginProcessingUrl("/auth/login")
.usernameParameter("email")
.successHandler(successHandler)
.failureHandler(failureHandler)
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/auth/logout")
.logoutSuccessUrl("/login?logout")
.addLogoutHandler(logoutHandler)
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID", "remember-me")
)
.rememberMe(remember -> remember
.tokenRepository(persistentTokenRepository)
.tokenValiditySeconds(14 * 24 * 60 * 60)
.userDetailsService(userDetailsService)
)
.sessionManagement(session -> session
.sessionFixation(fixation -> fixation.newSession())
.maximumSessions(3)
.expiredUrl("/login?expired")
);
return http.build();
}
}
Summary
- Form login uses
UsernamePasswordAuthenticationFilterto processPOST /login, delegates toAuthenticationManager, stores theSecurityContextin the HTTP session, and returns aJSESSIONIDcookie. - Customize with
loginPage(),loginProcessingUrl(),usernameParameter(),successHandler(),failureHandler(). - Use
AuthenticationSuccessHandlerfor role-based redirects; useAuthenticationFailureHandlerfor tracking and locking accounts. - Remember-me: use persistent (
PersistentTokenRepository) over simple (cookie hash) — token rotation detects cookie theft. - Always configure session fixation protection (
newSession()orchangeSessionId()).
Next: Article 7 covers HTTP Basic authentication — how it works, when to use it, and how to combine it with other mechanisms.