Part Two: Understanding the Basics.

Your Image

Lars Martens

14 Maart, 2025

In this part of the article, we’ll explore the basics of Spring Security. Our goal is to answer the question: What is needed to implement basic Spring Security? Rather than taking an exhaustive approach and covering every aspect of Spring Security, we’ll focus on the essential components necessary to implement security — the bare-bones approach, so to speak. And we will primarily focus on form-based authentication.

The Security Dependency.

To add Spring Security to our application, all we need to do is add the security dependency to the application's dependencies. Since I'm using Maven and Spring Boot in my project, this means adding the following code to the pom.xml file:


<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Adding the dependency does several things:

1) It enables authentication. Once the security dependency is added, all endpoints in the application will require authentication. This means that endpoints, which were previously accessible without logging in, will now require valid credentials to be accessed. So, if you add the security dependency to a Spring Boot application and notice that things stop working as expected, don’t be alarmed—this is normal behavior. By adding security, you're restricting access to your endpoints, which were previously open to everyone.

2) Spring will create a default user and a random password for this user. The username of this default user is 'user' and the password will be logged in the console when we start up our application. Each time we start our application, Spring will generate a different password and display it in the console. We can therefore still use our application after authentication has been enabled, but we need to login with the username 'user' and the password that is provided by Spring.

Note: Spring stops giving you this default user when you start implementing your own custom security. For instance Spring no longer provides a default user when you create a UserDetailsService, AuthenticationProvider, or AuthenticationManager bean. Or when you configure alternative authentication mechanisms such as OAuth2 or JWT Authentication. Don’t get distracted by the fact that I mention security beans or authentication mechanisms that you don't know yet. Just remember that if Spring no longer provides a default user, it’s because you’ve started implementing custom security yourself.

3) It enables CSRF protection. CSRF stands for Cross-Site Request Forgery. In this context, a request refers to an HTTP request, which is a message sent by a client (usually a web browser) to a server, to request data or perform an action on the internet.
This CSRF protection applies specifically to state-changing HTTP requests. This means that when a user tries to send a POST (creating new resources), PUT (updating existing resources), DELETE (removing resources) or PATCH (partially updating resources) request, Spring will not allow the request unless the correct CSRF token is also sent along with the request. This is to prevent malicious actors from impersonating others online, which is what Cross-Site Request Forgery attacks aim to do.
Don't dwell too much on CSRF for now. Just know that it exists and that Spring Security by default protects against it.

4) It enables form login. When an unauthenticated user attempts to access a protected resource in the application, Spring Security will redirect them to a default login page that contains an HTML form where the user is prompted to enter their username and password. Upon successful login, Spring creates a session for the user, grants access to the requested resource and redirects the user back to that resource they were trying to access before the login.
If the credentials are incorrect, Spring will display an error message (usually "Bad credentials") and present the login page again.
By default the login page is served at the /login endpoint.

Note: Spring is highly customizable. For example, we can configure where Spring redirects the user after a successful login, or specify a different page to redirect to if the login fails. Additionally, we can change the default login page from /login to any custom URL. Essentially, Spring allows us to modify almost every aspect of the security flow to suit our needs.

5) It enables password encoding. Spring creates a default user with username 'user' and a random password. Spring will first encode this password and it's only this encoded version of the password that will be shown in the console. It's good practice to never show passwords without encoding them. So Spring Security enables this by default. The default encryption method used by Spring is BCrypt encoding. Don't worry about BCrypt for now, just know that it's the default encryption method.

Configuring authentication and authorization.

Security comes down to authentication and authorization.

Authentication means answering the question: Is the user who he says he is? And this is usually handled through the verification of a username/password combination.

Authorization means answering the question: What is the user allowed to do or have access to in our application?

A Configuration class.

To configure these security aspects in our Spring application, we need a class that is annotated with two annotations, namely @Configuration and @EnableWebSecurity.

The @Configuration annotation tells Spring to treat the class as a source of bean definitions, indicating that the class will be used to configure the application. As a result, Spring processes the class during startup to register and manage the beans defined within it.
We could replace @Configuration with @Component, since both annotations ensure that Spring manages the class and any @Bean-annotated methods, creating beans during application startup. But it's best practice to use the most precise annotation for the intended purpose. And @Configuration is a more precise annotation for a class that is used to configure our application than @Component.

And while this goes beyond the scope of this article, I mention it for completeness: both these annotations share similarities, but they are not the same thing. A class annotated with @Configuration will create singleton beans, whereas a class annotated with @Component will create a new instance of the bean every time the @Bean-annotated method is called.

The @EnableWebSecurity annotation tells Spring that we are providing a custom security configuration and may want to override the default security behavior (like default form login, default user, password encoding, etc.).
However @EnableWebSecurity is not strictly required in a Spring Boot application. Spring Boot automatically enables Spring Security with its default settings when you add the spring-boot-starter-security dependency. If we want to customize these default settings, we can do so by defining the necessary configuration beans, such as SecurityFilterChain or UserDetailsService.
So, we don't need the @EnableWebSecurity annotation to add Spring Security to our Spring Boot application, nor to change the defaults. (Don't worry about SecurityFilterChain or UserDetailsService for now. They will be explained later.)

On the other hand, if we are building a Spring application (so not a Spring Boot application, but a Spring application), @EnableWebSecurity is necessary to activate Spring Security. This is because in a Spring application there is less "magic" that does things for us behind the scenes than in a Spring Boot application. And in that case, we explicitly need to tell our framework that it needs to enable Security.
Therefore, the class that handles the security configuration is usually annotated with both @Configuration and @EnableWebSecurity.

Now that we have annotated a class with @Configuration and @EnableWebSecurity, we are ready to start handling the security aspects ourselves. This is now our barebones configuration class. I named it ProjectConfig but you can name it differently. All that matters are the annotations on the class.


@Configuration
@EnableWebSecurity
public class ProjectConfig {

}

In Spring Security, the default behavior is to protect all endpoints by requiring authentication. Once a user is authenticated, the default behavior for authorization is that all endpoints become accessible.
Requiring authentication for all endpoints was a deliberate choice by the Spring developers. It’s better to secure everything by default and then open up only what needs to be accessible, rather than the other way around. Since Spring is highly customizable, we could choose to protect only the endpoints we believe need securing while leaving others open, but that would be a mistake. It’s wise to follow the framework’s philosophy and require authentication for everything. It’s simply the safer choice.
So, those are the defaults:
- All endpoints require authentication.
- Once authenticated, all endpoints become accessible.
If we want to deviate from these default and customize our application, then we need to create a SecurityFilterChain.

Authorization.

The SecurityFilterChain.

Let's first talk about authorization, and later we'll handle authentication. The object that is responsible for handling authorization in a Spring application is the SecurityFilterChain. Here's a code example of a SecurityFilterChain declared in our configuration class.


@Configuration
@EnableWebSecurity
public class ProjectConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> 
            auth.requestMatchers("/admin/**").hasRole("ADMIN")
                .requestMatchers("/user/**").hasRole("USER")
                .requestMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
        );
        return http.build();
    }
}

The SecurityFilterChain bean takes an HttpSecurity object as its parameter.HttpSecurity is an object that allows us to configure web-based security for HTTP requests. And since any interaction between our users and our application will be done in the form of an HTTP request, it's an important part of our application.

To handle authorization, we use the authorizeHttpRequests method of HttpSecurity. This method sets up the framework to enforce security rules on incoming HTTP requests, allowing us to control access based on conditions like user roles, authentication status, and other factors.

Inside this authorizeHttpRequests method, we provide a lambda function that takes an AuthorizationManagerRequestMatcherRegistry as a parameter. In the code above, we have given that AuthorizationManagerRequestMatcherRegistry the name auth. It's what gives us access to methods that define detailed authorization rules, such as matching specific URL patterns, enforcing role-based or authority-based access, and determining whether requests should be allowed, denied, or require authentication.

So, to quickly recapitulate: the authorizeHttpRequests method of HttpSecurity is where authorization rules are set. And the AuthorizationManagerRequestMatcherRegistry is the object we use within this method to actually specify and configure these rules.

Here are some of the methods of AuthorizationManagerRequestMatcherRegistry:

Method
What the method does
requestMatchers(String... patterns)
Defines URL patterns to match.
anyRequest()
Applies rules to all requests.
permitAll()
Grants access to all users.
authenticated()
Requires authentication for access.
hasRole(String role)
Grants access based on roles.
hasAuthority(String authority)
Grants access based on authorities.
denyAll()
Denies access to the specified request.

Let's go over the code example line by line to demonstrate what's going on:


@Configuration
@EnableWebSecurity
public class ProjectConfig {

     @Bean
1)   public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {	
2)       http.authorizeHttpRequests(auth -> 							
3)           auth.requestMatchers("/admin/**").hasRole("ADMIN")					
4)               .requestMatchers("/user/**").hasRole("USER")					
5)               .requestMatchers("/public/**").permitAll()					
6)               .anyRequest().authenticated()							
        );
7)       return http.build();									
    }
}

1) The method securityFilterChain is declared as a Spring Bean using @Bean, meaning Spring will manage and instantiate the object returned by the method as part of the application context. The method returns an object of type SecurityFilterChain and this bean is responsible for defining security configurations for the application.

2) The authorizeHttpRequests() method is called on the HttpSecurity object, allowing us to configure request authorization rules. In this method we use a lambda expression auth -> where auth is the name we gave to the AuthorizationManagerRequestMatcherRegistry.

3) We call the requestMatchers and hasRole methods of the AuthorizationManagerRequestMatcherRegistry to define specific access rules for different URL patterns. This line specifies that any request matching the /admin/** pattern (e.g., /admin/dashboard, /admin/settings) can only be accessed by users who have the ADMIN role.

4) Any request matching the /user/** pattern (e.g., /user/profile, /user/settings) requires the USER role to access.

5) This line allows unrestricted access to any request matching /public/**. This means that anyone, whether authenticated or not, can access endpoints that start with /public

6) This line ensures that any other request (not explicitly mentioned in the previous matchers) must be authenticated.

7) The http.build() call finalizes the HttpSecurity configuration and returns a SecurityFilterChain object, which Spring Security uses to enforce the security rules in the application.

The API for HttpSecurity can be found at: HttpSecurity API along with examples of its use.

Note: The next part goes beyond the scope of this article. If you really want to dive into the details of the above code, then continue reading, but know that this next section is completely unnecessary to get the most out of this article. In fact, it might make it even harder to understand the main points because it could distract and confuse you. This part is just me nerding out and wanting to explore the details. You can skip this section without any issues. I've marked where the main article resumes with "For those who want to get on with things, continue reading here" in bold text, so feel free to jump straight to that part.

This is the actual method signature of authorizeHttpRequests:


public HttpSecurity authorizeHttpRequests(
    Customizer<AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry> authorizeHttpRequestsCustomizer
)

As we can see the method takes a Customizer as it's parameter. So, what's a Customizer? A Customizer is just a functional interface that is often used in Spring Security for configuration purposes. Here is it's signature:


@FunctionalInterface
public interface Customizer {
    void customize(T t);
}

So, this functional interface takes an object and doesn't return anything. Customizers are used all over Spring Security because they make configurations more readable, maintainable, and keep the configuration context (in this case, the auth parameter) scoped to just where it's needed. This makes the code more maintainable and less prone to errors. Take this code for instance:


http.authorizeHttpRequests(auth -> {
      auth.requestMatchers("/admin/**").hasRole("ADMIN")
        .requestMatchers("/user/**").hasRole("USER")
        .anyRequest().authenticated();
});
	

It does the exact same thing, but is a lot less verbose than this code:


http.authorizeHttpRequests(new Customizer<AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry>() {
    @Override
    public void customize(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry auth) {
        auth.requestMatchers("/admin/**").hasRole("ADMIN")
            .requestMatchers("/user/**").hasRole("USER")
            .anyRequest().authenticated();
    }
});

The class we use in the Customizer is AuthorizeHttpRequestsConfigurer<T>. This is a generic class where T is the type parameter. In this case the type is HttpSecurity, which means that this AuthorizeHttpRequestsConfigurer class is configured to work with HttpSecurity.

And this configuration class has an inner class of type AuthorizationManagerRequestMatcherRegistry. This inner class maintains the registry of request matchers and their corresponding authorization rules. Or in other words, AuthorizationManagerRequestMatcherRegistry is responsible for keeping track of which HTTP requests should be protected and what security rules apply to them.

So, what we are actually doing in the authorizeHttpRequests method, is use a Customizer to allow us to use the methods of that inner class AuthorizationManagerRequestMatcherRegistry within a lambda function.

For those who want to get on with things, continue reading here:

Note that the matchers are considered in order. Therefore, the following code will not do what we want it to do.


@Configuration
@EnableWebSecurity
public class AuthorizeUrlsSecurityConfig {
        
   @Bean
   public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
           http
               .authorizeHttpRequests()
                   .requestMatchers("/**").hasRole("USER")
                   .requestMatchers("/admin/**").hasRole("ADMIN");
           return http.build();
   }

The first matcher matches every request and therefore will prevent the second matcher from doing its work. A normal user who does not have administrator credentials will still get access to our /admin endpoint based on the first matcher .requestMatcher("/**").hasRole("USER") that gives access to all endpoints starting with "/". And therefore, the second matcher .requestMatchers("/admin/**").hasRole("ADMIN") that is supposed to prevent this access, will not be effective.
Swapping the matchers, so the .requestMatchers("/admin/**").hasRole("ADMIN") comes first, solves the problem.

When we define a SecurityFilterChain, we are overwriting the defaults. This has some consequences. For one, Spring expects us to handle the security of our endpoints ourselves. Another consequence is that Spring no longer presents the user with a login form when they are not authenticated and attempt to access a protected endpoint. Take for instance this SecurityFilterChain:


@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/private").authenticated()
            .anyRequest().permitAll()
        );
    return http.build();
}

All requests are permitted except the /private endpoint which requires authentication. Since we have overwritten the default behavior, a user that is not logged in will no longer be presented with a login form when they try to visit our /private endpoint. What they will see instead is a generic error page, informing them of a Forbidden Error with error code 403.
By the way, do not do this in production. It's never a good idea to open up all endpoints like we have done here with the line .anyRequest().permitAll().

If we want to present our user with a login form for endpoints that require authentication, we now need to explicitly tell Spring that we want to do so. By adding .formLogin(Customizer.withDefaults()) to our SecurityFilterChain we tell Spring to present unauthenticated users with a login form whenever they attempt to access any protected resources (i.e., endpoints that require authentication). We could customize this login procedure, but I chose to use the default implementation of Spring's form-based authentication, which is indicated by Customizer.withDefaults().
A code example:


@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/private").authenticated()
            .anyRequest().permitAll()
        )
        .formLogin(Customizer.withDefaults());
    return http.build();
}

By explicitly adding formLogin(), we have re-enabled form-based authentication. Our user will be presented with a default login page configured by Spring at /login.

Authentication.

UserDetailsService and UserDetails.

Since we presented the user with a login page, Spring will also receive a username and password when the user fills out the form and submits it. It then needs to verify these credentials. To do this, Spring must know where to retrieve the necessary information to be able to handle this verification. Should it check a database, look in memory, or search the cache? Or does this information come from external authentication providers (OAuth2, LDAP), configuration files or environment variables (not recommended for production), API's or web services?

Spring expects us to provide it with a UserDetailsService to help it find this information.

A UserDetailsService is nothing more than an interface that has one method: loadUserByUsername(String username). This method does nothing but go to the place where it can find the information regarding our users, fetch the information about the current user based on the provided username and return this in the form of a UserDetails object.

A UserDetails object is an object that implements the UserDetails interface. Spring uses this interface to authenticate users. This object has to implement the following methods: getUsername(), getPassword(), getAuthorities(), isAccountNonExpired(), isAccountNonLocked(), isCredentialsNonExpired(), isEnabled(). These are all methods that answer the question of whether a user exists in our system, has the correct credentials, and if their account is still valid.

This might seem a bit complicated, but it is actually quite straightforward. When a user tries to access our application, Spring needs to verify their identity. To do so, Spring expects to receive the user’s information in a standardized format. Something it can easily understand and work with. That standardized format is the UserDetails object. Think of it as a well-organized report about the user, containing their username, password, and roles.

By providing Spring with a UserDetails object, we're giving it the necessary information to authenticate the user. Spring can then compare the credentials provided during login with the stored details and decide whether to grant access.

And to pass this information to Spring, we need a component that knows where to find user data and how to convert it into a UserDetails object. That’s exactly the role of the UserDetailsService.

Note: Spring Security provides a User class, which provides a convenient way to create a UserDetails object as it implements the UserDetails interface. In this class we only need to provide a username, a password and a collection of roles (can also be a collection that contains just one role). All other fields in the User class are automatically set when these values are provided. The API for this class can be found at: Spring Security User API.

Here is a bean that implements the UserDetailsService and makes use of this convenient User class. It has only one method that takes in a String and returns a UserDetails object:


@Bean
public UserDetailsService userDetailsService() {
    return username -> User.withUsername("username")
                           .password("{noop}password")
                           .roles("USER")
                           .build();
}

This current implementation serves only as an example. It will always return the same UserDetails object, with 'username' as its username and inform Spring that the password is not encrypted ({noop} = no encryption) and is 'password'. If a user would try to login with the credentials username/password, then this implementation would allow that user to be authenticated. However, in a real production environment, our UserDetailsService would return different usernames and actual encrypted passwords that are connected to those usernames.

Here is another example, it provides a quick way to test different roles and authentication without the hassle of setting up a database connection, as user information is stored in memory. We define two roles: a regular user and an administrator.


@Bean
public UserDetailsService users() {
    UserDetails user = User.builder() 
        .username("user")
        .password("{noop}user")
        .roles("USER")
        .build();
    UserDetails admin = User.builder()
        .username("admin")
        .password("{noop}admin")
        .roles("USER", "ADMIN")
        .build();
    return new InMemoryUserDetailsManager(user, admin);
}

PasswordEncoder.

Since all passwords should be encrypted, we need to tell Spring how we would like to handle this encryption in our application. To do this, we need to give Spring a bean of type PasswordEncoder. This is an interface that allows Spring to encrypt passwords and to compare a non-encrypted password provided by the user to its encrypted counterpart provided by UserDetails.

Here's an example of such a bean that returns a PasswordEncoder, which is in this case a BCryptPasswordEncoder. We can use a lot of different encryption methods in Spring. BCryptPasswordEncoder is a method that is commonly used. If we would like to use a different method, we can just change the return value of our PasswordEncoder bean:


@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

Spring Security 5 introduced the DelegatingPasswordEncoder as its default encoder. It's a multifunctional encoder that automatically detects the correct encoding based on the stored password's prefix and can handle all those different encodings.


@Bean
public PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

Here are examples of passwords encoded with different algorithms and their respective prefixes using Spring Security's PasswordEncoder.

Prefix
Hashing Algorithm
Example Encoded Password
{bcrypt}
BCrypt
{bcrypt}$2a$10$7EqJtq98hPqEX7fNZaFWoO...
{argon2}
Argon2
{argon2}$argon2i$v=19$m=1024,t=2,p=1$Y2F2...
{pbkdf2}
PBKDF2
{pbkdf2}sha256$100000$7d935e4f8b04...
{noop}
No Encoding
{noop}password123

The passwords are saved in their encrypted form and with their encryption prefix. The DelegatingPasswordEncoder will read the prefix, such as {pbkdf2}, and determine the appropriate password encoding to use based on the prefix.

Spring Security authenticates a user by following these steps:
Retrieve user details. It fetches the username and encrypted password from the UserDetails object provided by the UserDetailsService.
Encrypt the input password. It takes the password entered by the user in the login form and encrypts it using the same password encoder that was used to store the original password.
Compare passwords. It checks if the encrypted version of the input password matches the stored password from the UserDetails object.
Grant or deny access. If the passwords match, the user is successfully authenticated; otherwise, authentication fails.

With the PasswordEncoder, we've now covered all the basic elements needed to set up Spring Security. Time to recapitulate: there are five requirements for Spring Security to work in our application:
- The Spring Security dependency.
- A configuration class annotated with @Configuration and @EnableWebSecurity.
- A SecurityFilterChain.
- A UserDetailsService.
- A PasswordEncoder.

Note: There is actually only one requirement for Security to work in a Spring Boot application, not five—the spring-boot-starter-security dependency. With this dependency alone, Spring Security will be enabled with default settings. But these defaults are often impractical for real-world applications.

To customize the security configuration, only a SecurityFilterChain is required. As long as this SecurityFilterChain is defined in a component-scanned class, Spring Boot will handle the rest. Therefore, a class annotated with @Configuration and @EnableWebSecurity is not strictly necessary in a Spring Boot application.

However, if we are using Spring (without Spring Boot), then @EnableWebSecurity is required to activate Spring Security. Additionally, while the @Configuration annotation is not mandatory, it is considered best practice when defining configuration classes.

If we want to allow more users than just the default one generated by Spring Security, a UserDetailsService becomes necessary. This Service is responsible for verifying users by retrieving the user information we have stored. It constructs a UserDetails object based on the stored data and provides it to Spring Security for authentication.

A PasswordEncoder is not strictly required for Spring Security to function. We could store passwords in plain text, though doing so would be highly insecure. If we are going to ignore password encryption, why bother using Spring Security at all?

So, while the only real minimum requirement is the security dependency, we typically say there are five key elements to ensure real-world usability of Spring Security.

Conclusion.

In summary, these five building blocks form the foundation of Spring Security’s authentication and authorization process.
- The spring-boot-starter-security dependency activates security features by default, restricting access to endpoints.
- A configuration class, usually annotated with @Configuration and @EnableWebSecurity defines and customizes security settings.
- The SecurityFilterChain dictates how requests are secured, determining which endpoints require authentication and what roles users need.
- The UserDetailsService retrieves user information from a database or other storage and transforms it into a UserDetails object that Spring Security can process.
- Finally, the PasswordEncoder ensures passwords are securely hashed and compared during authentication.
Together, these components enable a flexible and robust security system.

I hope this article has given you a clear picture of the core building blocks of Spring Security and how they fit together. With this foundation, you should now be well-prepared to dive deeper into the world of Spring Security and explore it's finer details. If you're eager to dive in and explore the basics of Spring Security with a more hands-on approach, you can continue to part three of this article.



Part One: A Visual Overview of the Security Flow

Part Three: Applying the Basics