Getting Started with Spring Security: A Beginner’s Guide to Basic and Form Authentication
The first time you introduce Spring Security, a login screen suddenly appears out of nowhere and leaves you confused. Plenty of developers have had that exact experience.
This article walks developers who are new to Spring Security through the fundamentals of authentication, step by step. Starting from the minimal configuration, we’ll implement Basic authentication and then form authentication in turn, explaining what each setting means and where beginners commonly trip up.
What Is Spring Security?
Spring Security is a framework responsible for authentication (who you are) and authorization (what you can do) in Spring applications. The main authentication methods include Basic authentication, form authentication, OAuth2, and JWT. This article covers Basic authentication and form authentication.
Checking Spring Security’s Default Behavior
Let’s start by checking the default behavior. Simply adding the following dependency to pom.xml automatically protects every endpoint.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
When you start the application, the following log line appears in the console.
Using generated security password: 8e557245-73e2-4286-969a-ff57fe326336
This password changes every time you restart the application. Combined with the default username user, you can log in with it, and accessing the app from a browser shows an auto-generated login page.
This default password should only be used during development. If you want to fix it, you can configure it in application.yml as shown below, but you must always disable this in production.
spring:
security:
user:
name: user
password: dev-password
Behind this auto-configuration lies the mechanism we’ll look at next: SecurityFilterChain. For managing sensitive information in production, the Kubernetes deployment guide is a helpful reference.
The Basics of SecurityFilterChain
The core of Spring Security is the SecurityFilterChain. It defines security rules such as which URLs to protect and which authentication method to use. From Spring Security 6 onward, the standard approach is to register a SecurityFilterChain as a @Bean and configure it using the Lambda DSL notation.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.httpBasic(withDefaults()); // Specify the authentication method here
return http.build();
}
}
When you register a SecurityFilterChain as a @Bean in a configuration class annotated with @Configuration, Spring Boot automatically detects it and applies it as the security configuration for the entire application. This is also a practical example of DI in action. The .httpBasic(withDefaults()) part specifies Basic authentication as the authentication method. Note that withDefaults() is functionally identical to httpBasic()—it’s the recommended style in Spring Security 6+ for explicitly enabling the feature with its default settings.
The WebSecurityConfigurerAdapter inheritance pattern you often see in older articles was deprecated in Spring Security 5.7 and removed in 6.0 (Spring Boot 3.0). Replace overrides of configure(HttpSecurity) with a SecurityFilterChain @Bean, replace user registration with a UserDetailsService @Bean, and rewrite antMatchers() as requestMatchers().
A Minimal Basic Authentication Implementation
Now let’s implement Basic authentication using an in-memory user.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import static org.springframework.security.config.Customizer.withDefaults;
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.httpBasic(withDefaults());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
UserDetails user = User.builder()
.username("user")
.password(passwordEncoder.encode("password"))
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
}
In the UserDetailsService bean, we use InMemoryUserDetailsManager to create a test user in memory. Notice that we’re using BCryptPasswordEncoder right from the start. The older code example pattern User.withDefaultPasswordEncoder() has been deprecated since Spring Security 5.7 and should not be used even in development environments.
With Basic authentication, the username and password are Base64-encoded and sent in the Authorization header.
curl -u user:password http://localhost:8080/api/hello
# Specifying the header directly (Base64-encoded value of user:password)
curl -H "Authorization: Basic dXNlcjpwYXNzd29yZA==" http://localhost:8080/api/hello
Accessing this from a browser shows the standard authentication dialog. Keep in mind that Base64 is an encoding (a reversible transformation), not encryption. Over plain HTTP, an eavesdropper could easily recover the password, so always pair this with HTTPS (TLS) in production.
The Importance of a Password Encoder
Storing passwords in plain text is a serious problem, since a database breach would expose every user’s password. When you register a PasswordEncoder as a @Bean, Spring Security automatically uses this bean to verify passwords during authentication. A password encoded with BCryptPasswordEncoder looks something like $2a$10$..., which includes the version, salt, and hash value.
The spring-security-crypto Module and DelegatingPasswordEncoder
Encoders like BCryptPasswordEncoder live in a separate module called spring-security-crypto. For batch jobs or admin tools that only need password hashing, you can add just this module as a dependency (no <version> is needed since it’s resolved via Spring Boot’s BOM).
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
In practice, it’s recommended to use DelegatingPasswordEncoder rather than using BCryptPasswordEncoder directly.
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
This encoder stores hashes prefixed with an encoder ID like {bcrypt} (e.g., {bcrypt}$2a$10$...), and delegates to the appropriate encoder based on that ID during verification. This means you can migrate to a stronger algorithm in the future (like {argon2}) while still being able to verify existing users’ passwords, and since the default is BCrypt, switching from this article’s sample code won’t change behavior.
BCrypt’s computational cost (strength) can be specified via the constructor; the default is 10, and each increment roughly doubles the computation time. In production, a value around 10–12 is common. Even if BCrypt’s computation feels slow, don’t use NoOpPasswordEncoder—it’s deprecated and dangerous. For encrypting other sensitive information, config encryption with Jasypt is a convenient option.
Moving to Form Authentication
Basic authentication’s browser dialog isn’t very user-friendly, so form authentication is better suited to web applications aimed at general users. Let’s change the previous SecurityFilterChain as follows (the PasswordEncoder and UserDetailsService beans remain the same as in the previous section).
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
.formLogin(form -> form
.defaultSuccessUrl("/home", true)
.permitAll()
);
return http.build();
}
We replaced .httpBasic(withDefaults()) with .formLogin(). defaultSuccessUrl("/home", true) specifies the redirect destination on successful login, and .permitAll() allows access to the login page itself (without this, you’d get an infinite redirect loop). The default login page is auto-generated at /login.
Customizing the Login Page
To use a custom login page, configure it as follows.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/custom-login", "/css/**", "/js/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/custom-login")
.defaultSuccessUrl("/home", true)
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/custom-login?logout")
.permitAll()
);
return http.build();
}
.loginPage("/custom-login") specifies the custom login page, and .requestMatchers() allows access to static resources and the login page. The ** in .requestMatchers("/css/**") is an Ant-style pattern match, meaning “any path under /css/”.
.logout() configures the logout functionality. logoutSuccessUrl("/custom-login?logout") redirects back to the login page after logout, and the ?logout parameter lets you display a success message.
Here’s an example login page using Thymeleaf (placed at src/main/resources/templates/custom-login.html).
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Login</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div class="login-container">
<h1>Login</h1>
<div th:if="${param.error}" class="error">
Invalid username or password.
</div>
<div th:if="${param.logout}" class="success">
You have been logged out.
</div>
<form th:action="@{/custom-login}" method="post">
<div>
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
</div>
<div>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>
The key points are: specify the action attribute using th:action, method="post" is required, and the name attributes must default to username and password. You can also display login error messages using ${param.error}. POST forms using th:action automatically have a CSRF token hidden field embedded, so there’s no need to add one manually. If you place the CSS file at src/main/resources/static/css/style.css, it will automatically be served as a static resource.
An Overview of the Main HttpSecurity Configuration Options
Let’s take a look at what can be configured with HttpSecurity by combining the commonly used options into a single SecurityFilterChain (this is an excerpt of the configuration method calls; the HttpMethod and SessionCreationPolicy imports are omitted).
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 1. Per-URL authorization rules (evaluated top to bottom)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/css/**", "/js/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
.anyRequest().authenticated()
)
// 2. Authentication method (multiple can be specified)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/home", true)
.permitAll()
)
.httpBasic(withDefaults())
// 3. Logout
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID")
)
// 4. CSRF (disable for REST APIs; keep enabled for web apps)
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/**"))
// 5. Session management
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1)
)
// 6. Responses for unauthenticated (401/redirect) and unauthorized (403) requests
.exceptionHandling(ex -> ex
.accessDeniedPage("/access-denied")
)
// 7. Security headers (e.g., when you want to display the H2 console in an iframe)
.headers(headers -> headers
.frameOptions(frame -> frame.sameOrigin())
);
return http.build();
}
Here’s a summary of each option’s role.
| Configuration Method | Role | Notes |
|---|---|---|
authorizeHttpRequests() | Authorization rules per URL pattern | Evaluated top to bottom, so write narrower patterns first |
formLogin() / httpBasic() | Authentication method | Can be combined by specifying both |
logout() | Session invalidation and redirect on logout | Default logout URL is POST /logout |
csrf() | CSRF token validation | Enabled by default. Consider disabling only for stateless REST APIs |
sessionManagement() | Session creation policy, concurrent login limits | Use STATELESS for stateless authentication like JWT |
exceptionHandling() | AuthenticationEntryPoint for unauthenticated requests, AccessDeniedHandler for insufficient permissions | Used to return 401/403 as JSON in REST APIs |
headers() | Security headers like X-Frame-Options | Safe defaults are applied automatically |
cors() | CORS configuration | Needed when running the frontend on a different origin |
csrf() is covered in detail in Understanding Spring Security’s CSRF Protection Correctly - Configuration Differences Between REST APIs and Web Apps, and cors() is covered in How to Configure CORS in Spring Boot.
Separating SecurityFilterChain for REST APIs and Web Screens
In practice, requirements often diverge, such as “/api/** should use stateless Basic authentication (or JWT), while everything else uses form authentication.” In this case, define multiple SecurityFilterChain beans, scope each one’s target URLs with securityMatcher(), and specify the evaluation order with @Order.
@Configuration
public class SecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**") // This chain only handles /api/**
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(withDefaults())
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/css/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form.loginPage("/login").permitAll());
return http.build();
}
}
Chains are tried for a match starting with the smallest @Order value, and only the first matching chain is applied. A chain without a securityMatcher() matches all requests, so it must always be placed last. Getting the order wrong leads to the classic problem of “accessing /api/** redirects to the login page.”
How Does the Authentication Process Flow?
Both form authentication and Basic authentication are processed internally through the following flow:
- An authentication filter (
UsernamePasswordAuthenticationFilter/BasicAuthenticationFilter) extracts authentication credentials from the request and creates an unauthenticatedAuthenticationobject. - The
AuthenticationManager(implemented byProviderManager) delegates in turn to the registeredAuthenticationProviders. DaoAuthenticationProviderretrieves the user viaUserDetailsService#loadUserByUsername()and verifies the password withPasswordEncoder#matches().- On success, the authenticated
Authenticationis stored inSecurityContextHolder, and can subsequently be referenced via@AuthenticationPrincipaland similar mechanisms. - On failure, an
AuthenticationExceptionis thrown, resulting in a 401 for Basic authentication, or a redirect to/login?errorfor form authentication.
The reason authentication worked in this article just by registering UserDetailsService and PasswordEncoder as @Beans is that auto-configuration detects these beans and wires them into DaoAuthenticationProvider. If you want to fetch users from a database, you only need to swap out this UserDetailsService—the procedure is covered in Implementing Database Authentication in Spring Security - UserDetailsService and JdbcUserDetailsManager.
The AuthenticationEntryPoint determines the response when an unauthenticated request tries to access a protected resource. With httpBasic(), this returns 401 along with a WWW-Authenticate: Basic header (which is what prompts the browser to show the authentication dialog); with formLogin(), a 302 redirect to /login is returned. If you want to return a JSON error body for a REST API, configure a custom AuthenticationEntryPoint (import HttpServletResponse from the jakarta.servlet.http package).
http
.httpBasic(basic -> basic.realmName("my-api"))
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"unauthorized\"}");
})
);
Common Configuration Errors Beginners Run Into and How to Fix Them
Here are some typical errors you’re likely to encounter when getting started.
1. “There is no PasswordEncoder mapped for the id “null"" Error
This occurs when you try to use a plain-text password without configuring a password encoder. Register a PasswordEncoder as a @Bean and encode the password.
2. Infinite Redirect to the Login Page
If the login page itself requires authentication, an infinite redirect occurs. Call .permitAll() within .formLogin(), and also explicitly allow it with .requestMatchers("/custom-login").permitAll().
3. CSRF Token Error
Using Thymeleaf’s th:action automatically embeds the CSRF token. If you’re writing HTML manually, add <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>.
Enabling Debug Logging
Debug logging, which outputs detailed internal behavior, is useful for troubleshooting. Add the following to application.yml.
logging:
level:
org.springframework.security: DEBUG
Criteria for Choosing an Authentication Method
Which should you choose: Basic authentication or form authentication? Basic authentication is well-suited for protecting REST API endpoints, simple admin screens or development tools, and stateless applications accessed primarily via tools like curl or Postman. On the other hand, for browser-based web applications aimed at general users where you need a customized login page and session management, choose form authentication.
Note that httpBasic() and formLogin() can be combined within the same chain—browsers will get form authentication, while requests with an Authorization: Basic ... header get Basic authentication. However, since this can easily cause confusion, it’s safer to split the chains for REST API and Web UI as introduced earlier.
You could also use Spring Boot Profiles to switch configuration by environment to use Basic authentication in development and form authentication in production.
Next Steps
Try out the fundamentals implemented in this article in your own project first, then move on to the following articles depending on your requirements.
- For database integration, see Implementing Database Authentication in Spring Security - Persisting Users with UserDetailsService and JdbcUserDetailsManager. For storing sensitive information, you can also use How to Encrypt Sensitive Information in Config Files with Jasypt
- For external service login integration, see How to Implement Google Login (OAuth2) in Spring Boot; for protecting APIs with tokens, see How to Configure Spring Boot as an OAuth2 Resource Server
- For token-based stateless authentication, see How to Implement Stateless Authentication with Spring Security + JWT
- For role-based access control, see Spring Security Method Security - Implementing RBAC with @PreAuthorize
- For understanding how CSRF protection works and when it’s safe to disable, see Understanding Spring Security’s CSRF Protection Correctly
- For testing authenticated endpoints, see Unit Testing Controllers with MockMvc, which can be combined with
@WithMockUserand similar tools - For safely handling passwords and JWT signing keys in production, the Secret resources introduced in How to Deploy a Spring Boot App to Kubernetes are the standard approach