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 MethodRoleNotes
authorizeHttpRequests()Authorization rules per URL patternEvaluated top to bottom, so write narrower patterns first
formLogin() / httpBasic()Authentication methodCan be combined by specifying both
logout()Session invalidation and redirect on logoutDefault logout URL is POST /logout
csrf()CSRF token validationEnabled by default. Consider disabling only for stateless REST APIs
sessionManagement()Session creation policy, concurrent login limitsUse STATELESS for stateless authentication like JWT
exceptionHandling()AuthenticationEntryPoint for unauthenticated requests, AccessDeniedHandler for insufficient permissionsUsed to return 401/403 as JSON in REST APIs
headers()Security headers like X-Frame-OptionsSafe defaults are applied automatically
cors()CORS configurationNeeded 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:

  1. An authentication filter (UsernamePasswordAuthenticationFilter / BasicAuthenticationFilter) extracts authentication credentials from the request and creates an unauthenticated Authentication object.
  2. The AuthenticationManager (implemented by ProviderManager) delegates in turn to the registered AuthenticationProviders.
  3. DaoAuthenticationProvider retrieves the user via UserDetailsService#loadUserByUsername() and verifies the password with PasswordEncoder#matches().
  4. On success, the authenticated Authentication is stored in SecurityContextHolder, and can subsequently be referenced via @AuthenticationPrincipal and similar mechanisms.
  5. On failure, an AuthenticationException is thrown, resulting in a 401 for Basic authentication, or a redirect to /login?error for 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.