Securing Your API with Spring Security

This post links to: Building a Spring Boot CRUD App With Postgres from Scratch: The Complete Guide.

This guide follows on from Refactoring Our Application With Lombok.

In this blog post we will discuss adding Spring Security to our API. Even a simple CRUD API like ours needs some sort of security, especially if one day we want to share it with the world.

Why Add Security?

Now the answer to this might sound obvious, we don't want unauthenticated or unauthorised people to access and use our API. Simple. However, there are some more specific points to mention:

Common Types of Web Security

Now we have an understanding of why we need security, the next step is to choose the right security for your application/API.

In this blog we are going to be using HTTP basic auth for our web security. This is mainly due to this being a demo, and I'm not expecting much traffic to my website. However, if I was to make this into something that would have a lot of users and traffic, I'd use a different type of web security.

I'd recommend reading my other page about other common types of web security. This page goes into more detail about what HTTP basic auth is and alternative web security methods.

Implementing HTTP Basic Auth in Spring

Now we've evaluated some web security options and decided to use HTTP Basic Auth, let's dive into how we can implement this in our Spring application.

Setting Up Dependencies

Let's first add the Spring Boot security starter dependency into our build.gradle file:

// build.gradle
dependencies {
    //other deps...
    implementation 'org.springframework.boot:spring-boot-starter-security'
    // other deps...
}

You'll notice that with Spring Boot starter dependencies, we do not need to put the version. This is inherited from the version set by the org.springframework.boot plugin defined at the top of the build.gradle file.

Now we've added this dependency, if you run the application you'll notice a few changes.

Firstly, in the logs you'll see something like this:

2026-05-20T20:49:37.555+01:00  WARN 44326 --- [crud.app] [           main] .s.s.UserDetailsServiceAutoConfiguration : 

Using generated security password: 6366a6d5-bc66-4a7e-9f44-6cb6ccbec35b

This generated password is for development use only. Your security configuration must be updated before running your application in production.

2026-05-20T20:49:37.557+01:00  INFO 44326 --- [crud.app] [           main] r$InitializeUserDetailsManagerConfigurer : Global AuthenticationManager configured with UserDetailsService bean with name inMemoryUserDetailsManager

Spring Boot security added a generated password for development use only.

Then let's say we wanted to list all our users by running the following in the terminal:

curl -X GET http://localhost:8080/api/users

You'll see that we receive no response, when usually we get an empty array like so: []

We can use the -i flag to get some more info like so:

curl -i -X GET http://localhost:8080/api/users

and we get something like this:

HTTP/1.1 401
// Some other response information

Ahh here's the underlying issue, you can now see that we get a 401 unauthorised response, exactly what we were expecting.

So right out the box, Spring Boot security will secure all your endpoints. But what if we want some endpoints publicly accessible, like Swagger endpoints? Well, we will get to that later.

We can also see a login page now when navigating to http://localhost:8080/api/users in the browser. We can use the username user and the generated password copied from the logs to log in and view the page.

The user and password can also be used in a cURL request like so:

curl -i -u user:<replace_with_generated_password> http://localhost:8080/api/users

So that's it right, we are now fully secure and ready to take on the internet.

Well... not quite.

Like previously mentioned, Basic Auth should only be used for apps that aren't critical, don't have a lot of user traffic and are mainly demos and internal tools behind firewalls and proxies.

Even if we kept Basic Auth, we only have one shared user, with a randomly generated password each time the application is deployed. Also, what if we wanted to add a read-only user for our application. With the current setup, this would be impossible. So we still have a little bit of work to do.

Adding the Basic Auth security config

The next stage is to do what the logs say: "Your security configuration must be updated before running your application in production."

So let's update our security configuration for our application. The way to do that is to define our security configuration using the @Configuration annotation and include 3 important beans: a SecurityFilterChain, a UserDetailsService and a PasswordEncoder, like in the example below:


@Configuration
public class SecurityConfig {

    private static final String USER_ROLE = "USER";
    private static final String ADMIN_ROLE = "ADMIN";

    private static final String SWAGGER_UI_PATH = "/swagger-ui/**";
    private static final String V_3_API_DOCS_PATH = "/v3/api-docs/**";
    private static final String API_DOCS_PATH = "/docs/**";
    private static final String API_DOCS_JSON_PATH = "/docs-json/**";
    private static final String API_USERS_PATH = "/api/users/**";

    @Bean
    public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {

        return http
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(SWAGGER_UI_PATH,
                                         V_3_API_DOCS_PATH,
                                         API_DOCS_PATH,
                                         API_DOCS_JSON_PATH).permitAll()
                        .requestMatchers(HttpMethod.GET, API_USERS_PATH).hasAnyRole(USER_ROLE, ADMIN_ROLE)
                        .requestMatchers(API_USERS_PATH).hasRole(ADMIN_ROLE)
                        .anyRequest().authenticated()
                )
                .httpBasic(Customizer.withDefaults())
                .build();
    }

    @Bean
    public UserDetailsService userDetailsService(final PasswordEncoder passwordEncoder) {

        final var user = User.withUsername("user")
                             .password(passwordEncoder.encode("password"))
                             .roles(USER_ROLE)
                             .build();

        final var admin = User.withUsername("admin")
                              .password(passwordEncoder.encode("password"))
                              .roles(ADMIN_ROLE)
                              .build();

        return new InMemoryUserDetailsManager(user, admin);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {

        return new BCryptPasswordEncoder();
    }
}

Security Filter Chain

This bean defines how the incoming HTTP requests should be secured. Spring Security will sit in front of the controllers and intercept any requests before they reach the application. The security filter chain defines:

In the previous example:


@Bean
public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {

    return http
            .csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers(SWAGGER_UI_PATH,
                                     V_3_API_DOCS_PATH,
                                     API_DOCS_PATH,
                                     API_DOCS_JSON_PATH).permitAll()
                    .requestMatchers(HttpMethod.GET, API_USERS_PATH).hasAnyRole(USER_ROLE, ADMIN_ROLE)
                    .requestMatchers(API_USERS_PATH).hasRole(ADMIN_ROLE)
                    .anyRequest().authenticated()
            )
            .httpBasic(Customizer.withDefaults())
            .build();
}

We are saying:

If there was no security filter chain, everything would be locked down by default.

User Details Service

This tells Spring Security how to find and validate users making HTTP requests. If someone tries to access the application with a username user and password password, Spring Security needs to know whether this user exists, whether they're authenticated and what access they have?

Right now for demo purposes we are using the InMemoryUserDetailsManager which hardcodes the users directly in the code. As you can imagine this isn't a great look for the future of our application when we deploy into production. Later in this blog we will instead look at using a database to store and access users.

For now the main takeaway is this defines the user lookup service for Spring Security.

Password Encoder

This bean defines how to handle password hashing and verification. As previously mentioned, passwords should not be stored in plaintext, instead they should be stored as hashes.

Spring Security will use the password encoder to compare the raw password with the stored hash password and verify if they match or not.

These 3 components all work together: the security filter chain filters the request, the user details service finds the user and the password encoder verifies the password of the given user. Credentials are verified and roles are checked. The request is then either allowed or denied.

Testing Our Security

We have now set up our security configuration, it's now time to test it.

Manual Testing

We can do this manually like so:

curl -i -u user:password http://localhost:8080/api/users

And you should receive a HTTP/1.1 200 response with an empty array.

Run the following:

curl -i http://localhost:8080/api/users

And you'll receive a HTTP/1.1 401.

Run this:

curl -i -u admin:password \
-X POST \
-H "Content-Type: application/json" \
-d '{"name":"jon","email":"jon@example.com", "age":40}' \
http://localhost:8080/api/users

And you can create a new user.

Run this:

curl -i -u user:password \
-X POST \
-H "Content-Type: application/json" \
-d '{"name":"jon","email":"jon@example.com"}' \
http://localhost:8080/api/users

And it'll fail to create a new user as the user role doesn't have permissions to modify users.

Looks like our security is working. However, what if someone makes a change like adding an ops role that can update users, or adding a new API endpoint that needs to be secured. It would take a lot of time to manually test all these new changes. That's where automated testing comes in.

Automated Testing

Instead of manually testing all the endpoints and checking if they are secured correctly, we can write tests to do this for us and run them everytime we make a change to our application.

Firstly, we can add the Spring Security test dependency to our build.gradle file:

// build.gradle
dependencies {
    //other deps...
    testImplementation 'org.springframework.security:spring-security-test'
    // other deps...
}

Next we can define a MockMvc test like the example below:


@SpringBootTest
@AutoConfigureMockMvc
class UserSecurityTest {

    private static final String USERS_API_PATH = "/api/users";
    private static final String USER_USERNAME = "user";
    private static final String USER_PASSWORD = "password";
    private static final String ADMIN_USERNAME = "admin";
    private static final String ADMIN_PASSWORD = "password";

    @Autowired private MockMvc mockMvc;

    @Test
    void returnsUnauthorisedWhenNoCredentialsProvided() throws Exception {

        mockMvc.perform(get(USERS_API_PATH))
               .andExpect(status().isUnauthorized());
    }

    @Test
    void allowsAUserRoleToReadUsers() throws Exception {

        mockMvc.perform(get(USERS_API_PATH)
                                .with(httpBasic(USER_USERNAME, USER_PASSWORD)))
               .andExpect(status().isOk());
    }

    @Test
    void forbidsTheUserRoleFromCreatingUsers() throws Exception {

        final var userCreateRequest = randomUserCreateRequest();

        mockMvc.perform(post(USERS_API_PATH)
                                .with(httpBasic(USER_USERNAME, USER_PASSWORD))
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(asJsonString(userCreateRequest)))
               .andExpect(status().isForbidden());
    }

    @Test
    void allowsTheAdminRoleToCreateUsers() throws Exception {

        final var userCreateRequest = randomUserCreateRequest();

        mockMvc.perform(post(USERS_API_PATH)
                                .with(httpBasic(ADMIN_USERNAME, ADMIN_PASSWORD))
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(asJsonString(userCreateRequest)))
               .andExpect(status().isOk());
    }
}

This will start the application and then use MockMvc as an HTTP client to make requests to our user API. Now if we make any updates, our tests will fail, and we will hopefully catch any security flaws.

Implementing Basic Auth With a Database

As mentioned before, we want to make sure we're not storing user details as hardcoded values within our application. Instead, we can use our existing database setup to manage and store our users in a more secure way.

To do this, we first need to update our User model to include a password field and a role field:


@Builder(toBuilder = true)
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "users")
public class User {

    // Other fields...

    @Column(name = "password")
    private String password;

    @Enumerated(EnumType.STRING)
    @Column(name = "role")
    private UserRole role;
}

The user roles can be defined as an enum like so:

public enum UserRole {

    USER, ADMIN
}

We also need to update our UserRepository to find a user by a username:


@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByName(final String name);
}

This allows us to remove the UserDetailsService in our security config:


@Bean
public UserDetailsService userDetailsService(final PasswordEncoder passwordEncoder) {

    final var user = User.withUsername("user")
                         .password(passwordEncoder.encode("password"))
                         .roles(USER_ROLE)
                         .build();

    final var admin = User.withUsername("admin")
                          .password(passwordEncoder.encode("password"))
                          .roles(ADMIN_ROLE)
                          .build();

    return new InMemoryUserDetailsManager(user, admin);
}

And add a DatabaseUserDetailsService that implements the UserDetailsService and overrides the loadUserByUsername() method which is used by Spring Security for authentication, this will use our new findByName() method we defined in the repository:


@Service
public class DatabaseUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public DatabaseUserDetailsService(final UserRepository userRepository) {

        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(final String username) {

        final var user = userRepository.findByName(username)
                                       .orElseThrow(() -> new UserNotFoundException(username));

        return User.withUsername(user.getName())
                   .password(user.getPassword())
                   .roles(user.getRole().name())
                   .build();
    }
}

Seeding Our First Admin User

Next we need some way to seed our first admin user, and use the admin user to create subsequent users and manage our application.

There are a few ways to do this. One way would be to manually create the user directly in the database or use some sort of script to create the user. We can also do this in our application using the CommandLineRunner, which will run on application startup and create our admin user for us.

However, we want to avoid storing any details in our application or uploading them to GitHub. So we can do this using application properties and environment variables like so:

Create some admin properties:


@Setter
@Getter
@ConfigurationProperties(prefix = "app.security.admin")
public class AdminProperties {

    private String password;
    private String email;
}

Enable the configuration props:


@Configuration
@EnableConfigurationProperties(AdminProperties.class)
public class SecurityConfig {
    // Configuration...
}

Add the following properties to our application.yml file:

# Other props...
app:
  security:
    admin:
      password: ${ADMIN_PASSWORD}
      email: ${ADMIN_EMAIL}

Finally, create a user seeder to seed our admin user:


@Configuration
public class UserSeeder {

    private static final String ADMIN_USERNAME = "admin";

    @Bean
    public CommandLineRunner seedAdminUser(final UserRepository userRepository,
                                           final PasswordEncoder passwordEncoder,
                                           final AdminProperties adminProperties) {

        return args -> {
            if (userRepository.findByName(ADMIN_USERNAME).isEmpty()) {
                final var admin = new User();
                admin.setName(ADMIN_USERNAME);
                admin.setPassword(passwordEncoder.encode(adminProperties.getPassword()));
                admin.setEmail(adminProperties.getEmail());
                admin.setRole(UserRole.ADMIN);
                userRepository.save(admin);
            }
        };
    }
}

Now we can run our application like so to seed our admin user:

ADMIN_PASSWORD=supersecure ADMIN_EMAIL=admin@example.com ./gradlew bootRun

This will create our admin user on the start-up of the application. We would probably only do this once. Then any subsequent runs wouldn't create the admin user as it already exists in the database.

For simplicity this example creates an initial admin account at startup using configuration properties. In production systems you would typically avoid storing default credentials in application files and instead use environment variables, secrets managers, or a dedicated user onboarding flows to create admins and other users.

Update User Creation Logic

The last step would be to update our user creation logic to create users with roles and passwords. We want to make sure only admins can create users and that users are given the USER role when created.

We can start off by adding a password field to our UserCreateRequest DTO:


@Schema(description = "Payload to create a new user")
public record UserCreateRequest(

        //... Other fields
        @Schema(description = "User password, should be secured over HTTPS", example = "1aB%2cD$", requiredMode = Schema.RequiredMode.REQUIRED)
        @NotNull(message = "Password is required")
        @Pattern(regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=!]).{8,}$",
                message = "Password must contain at least one digit, one lowercase, one uppercase, and one special character, and be at least 8 characters long.")
        String password) {}

For now, we will always default the new user to the USER role. Eventually, we could update the UserCreateRequest to include the desired role for the new user or add a separate endpoint to change the role for a user. We can default the user role like so in our controller method:


@Operation(summary = "Create a new user", description = "Creates and returns the newly created user")
@ApiResponses({
        @ApiResponse(responseCode = "200", description = "User created successfully"),
        @ApiResponse(responseCode = "400", description = "Invalid request data")
})
@PostMapping
public UserResponse createUser(@Valid @RequestBody final UserCreateRequest user) {

    final var newUser = userService.createUser(user, UserRole.USER);
    log.info("Created new user with ID: {}", newUser.getId());
    return userMapper.toResponse(newUser);
}

NOTE: the UserService and UserMapper will also be updated to take the new user role parameter.

So now when we call the POST - /api/users endpoint, we can pass in the password like so:

curl -i -u admin:<WHATEVER_YOUR_ADMIN_PASSWORD_IS> \
-X POST \
-H "Content-Type: application/json" \
-d '{"name":"jon","email":"jon@example.com", "age":40, "password": "1aB%2cD$"}' \
http://localhost:8080/api/users

NOTE: also when sending the password over basic auth, you should always enable HTTPS to ensure the traffic is encrypted.

And that's it, we now have some Basic Authentication working with Spring Security backed by a database and the ability to create new users with the user role!

Securing Swagger Docs with Basic Auth

The last thing to do is to update our Swagger doc to allow users with basic authentication to try out our endpoints.

To enable basic auth for swagger, we simply need to add the following OpenAPI bean configuration:


@Configuration
public class OpenApiConfig {

    private static final String SECURITY_SCHEME_NAME = "basicAuth";

    @Bean
    public OpenAPI openAPI() {

        return new OpenAPI()
                .addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME_NAME))
                .components(
                        new Components().addSecuritySchemes(
                                SECURITY_SCHEME_NAME, new SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("basic")
                        )
                );
    }
}

If you navigate to http://localhost:8080/docs, you will now see the following "Authorize" button, which we can select and enter our basic auth credentials. Now all the Swagger endpoints will be authorised when trying them out.

swagger-authorise-button.png

Please Read This - Real World Caveats

Before considering basic auth as your web security method, please consider the following:

What's Next?

So to recap we:

GitHub Example

Stay tuned for the upcoming blog in the series about handling database migration!