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 /login with parameters username and password
  • Redirects to / on success
  • Redirects to /login?error on failure
  • Provides a logout page at GET /logout and processes it at POST /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 UsernamePasswordAuthenticationFilter to process POST /login, delegates to AuthenticationManager, stores the SecurityContext in the HTTP session, and returns a JSESSIONID cookie.
  • Customize with loginPage(), loginProcessingUrl(), usernameParameter(), successHandler(), failureHandler().
  • Use AuthenticationSuccessHandler for role-based redirects; use AuthenticationFailureHandler for tracking and locking accounts.
  • Remember-me: use persistent (PersistentTokenRepository) over simple (cookie hash) — token rotation detects cookie theft.
  • Always configure session fixation protection (newSession() or changeSessionId()).

Next: Article 7 covers HTTP Basic authentication — how it works, when to use it, and how to combine it with other mechanisms.