HTTP Basic & Form-Based Authentication
The two classic authentication mechanisms: when Basic is appropriate, configuring form login properly, custom success and failure handlers, logout, and account lockout that is not a DoS.
On this page
Form login is still how most user-facing applications authenticate, and Spring's defaults get the important parts right. The work is in the handlers — what happens on success, on failure, and on repeated failure.
Key Takeaways
- Basic suits machine-to-machine over TLS; form login suits users.
- Spring regenerates the session id on login by default, which prevents session fixation.
- Return one message for every failure — distinguishing them is an enumeration oracle.
- Lock progressively, never permanently, or lockout becomes a denial-of-service tool.
- Logout must invalidate the session and clear the cookie, not just redirect.
Form login
@Bean
SecurityFilterChain webChain(HttpSecurity http,
AuthenticationSuccessHandler successHandler,
AuthenticationFailureHandler failureHandler) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/register", "/css/**", "/js/**").permitAll()
.anyRequest().authenticated())
.formLogin(form -> form
.loginPage("/login")
.loginProcessingUrl("/login") // where the form POSTs
.usernameParameter("email")
.passwordParameter("password")
.successHandler(successHandler)
.failureHandler(failureHandler)
.permitAll())
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/?loggedOut")
.invalidateHttpSession(true)
.clearAuthentication(true)
// Without this the cookie lingers in the browser; harmless with an
// invalidated session, untidy and confusing during debugging.
.deleteCookies("JSESSIONID"))
.sessionManagement(session -> session
// New session id on login. Defends against session fixation, where
// an attacker plants a known id before the victim authenticates.
.sessionFixation(SessionFixationConfigurer::changeSessionId)
.maximumSessions(3)
.maxSessionsPreventsLogin(false)); // newest login wins
return http.build();
}Session fixation protection is on by default and worth understanding: without it, an attacker who can set a session cookie before login — via a link with a session parameter, or an XSS on a subdomain — holds a valid session for the victim once they authenticate. Regenerating the id on login makes the planted id worthless.
maximumSessions carries a dependency that is easy to miss: the session registry only learns a
session has ended if an HttpSessionEventPublisher bean is registered. Without it, expired and
logged-out sessions are never removed, the count only ever climbs, and users start being refused their
fourth login of the month. Register the bean, or the limit quietly becomes a lockout.
The login form needs the CSRF token; Thymeleaf adds it automatically to any form using th:action.
Logout is a POST for the same reason, which catches out anyone who wires up a plain
<a href="/logout"> and finds it does nothing. That is deliberate — a GET logout can be triggered by
an image tag on any site on the internet, and while forcibly signing someone out is a mild attack, it
is one you avoid for free by using a form.
Handlers
@Component
public class LoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private final LoginAttemptService attempts;
private final UserService users;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
attempts.recordSuccess(authentication.getName());
users.recordLogin(authentication.getName(), request.getRemoteAddr());
// Extending SavedRequestAware preserves the "return where you were
// going" behaviour rather than always dumping people on a dashboard.
super.onAuthenticationSuccess(request, response, authentication);
}
}
@Component
public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private final LoginAttemptService attempts;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception)
throws IOException, ServletException {
String username = request.getParameter("email");
attempts.recordFailure(username, request.getRemoteAddr());
// ONE message regardless of cause. "No such user" versus "wrong
// password" is a free account-enumeration oracle.
setDefaultFailureUrl("/login?error");
super.onAuthenticationFailure(request, response, exception);
}
}Timing leaks the same information as messages. If an unknown username returns instantly while a known one takes 300ms of password hashing, an attacker can enumerate accounts by response time alone. Hash a dummy password when the user is not found so both paths cost the same.
Spring's DaoAuthenticationProvider already does this — it runs the configured encoder against a
throwaway hash when no user is found — so the default behaviour is sound. The leak reappears the
moment someone writes a custom AuthenticationProvider that returns early on a missing user, which is
the natural way to write one.
Progressive lockout
@Service
public class LoginAttemptService {
private final StringRedisTemplate redis;
public void recordFailure(String username, String ip) {
// Track both. Per-account stops targeted guessing; per-IP stops
// credential stuffing spread across many accounts.
increment("login:fail:user:" + username, Duration.ofMinutes(15));
increment("login:fail:ip:" + ip, Duration.ofMinutes(15));
}
public Duration delayFor(String username) {
long failures = count("login:fail:user:" + username);
return switch ((int) Math.min(failures, 6)) {
case 0, 1, 2 -> Duration.ZERO;
case 3 -> Duration.ofSeconds(1);
case 4 -> Duration.ofSeconds(5);
case 5 -> Duration.ofSeconds(30);
default -> Duration.ofMinutes(5);
};
}
public void recordSuccess(String username) {
redis.delete("login:fail:user:" + username);
}
}Notify the account owner when failures cross a threshold. A user who receives "someone tried to sign in to your account six times" and did not do it will change their password, which is a better outcome than any automated lock.
HTTP Basic
@Bean
@Order(1)
SecurityFilterChain machineChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/internal/**")
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().hasRole("SERVICE"))
.httpBasic(basic -> basic.realmName("acme-internal"))
// Redirect any plaintext request; Basic sends credentials in a
// trivially decodable header.
.requiresChannel(channel -> channel.anyRequest().requiresSecure());
return http.build();
}Basic sends Authorization: Basic base64(user:password) on every request. Base64 is an encoding, not
encryption — over plaintext HTTP the credentials are readable by anything on the path. It is
acceptable only over TLS, and only for machine credentials that are rotatable and scoped.
For users it fails on the things that matter: no logout, no MFA, no styled login page, and the credential is transmitted repeatedly rather than exchanged once for a session.
Remember-me
.rememberMe(remember -> remember
.key(System.getenv("REMEMBER_ME_KEY"))
.tokenRepository(persistentTokenRepository) // series + rotating token
.tokenValiditySeconds((int) Duration.ofDays(14).toSeconds())
.userDetailsService(userDetailsService))Use the persistent token repository rather than the hash-based default. It stores a series identifier with a token that rotates on each use, so a stolen cookie is detectable: when the old token is presented after rotation, the server knows two parties hold it and invalidates the whole series.
The hash-based variant cannot detect theft and cannot be invalidated before expiry.
Sessions across more than one instance
One deployment detail cuts across everything above. Sessions live in the servlet container's memory by default, so the moment a second instance sits behind a load balancer, a user authenticated on one pod is anonymous on the other. Sticky sessions hide the problem until a pod restarts during a deploy and signs everyone on it out.
Spring Session backed by Redis moves the store out of the process, which fixes the routing problem and makes session invalidation, the concurrency registry and remember-me all behave consistently across instances. It is a small change — a dependency and a property — and much easier to make before the first horizontal scale-out than after.
What to take away
Use form login for users and Basic only for machines over TLS. Keep the failure message and the response timing identical whichever way login fails. Lock progressively rather than permanently, and alert the account owner. Regenerate the session on login, invalidate it properly on logout, and use rotating persistent tokens for remember-me.
Frequently Asked Questions
Is HTTP Basic ever acceptable?
Why does my login redirect to the wrong page?
How should account lockout work?
Related tutorials
- Password Management & EncodingStoring passwords properly: choosing between BCrypt, Argon2 and scrypt, DelegatingPasswordEncoder for zero-downtime migration, strength rules, and breached-password checks.
- In-Memory & JDBC AuthenticationWhere user credentials live: in-memory users for tests, JdbcUserDetailsManager and its schema, writing a custom UserDetailsService, and seeding an initial administrator safely.
- Spring Security Architecture Deep DiveHow Spring Security actually works: the filter chain and its ordering, SecurityContextHolder, the AuthenticationManager delegation model, and where to plug in custom logic.
- JWT Authentication Deep DiveJWTs done safely: structure and claims, why RS256 beats HS256, key rotation with JWKS, the alg=none and key-confusion attacks, and how to revoke a stateless token.