In this post, we’ll explore how to use Spring Boot and Spring Security to manage access control for our application. Specifically, we’ll look at how to set up user roles to restrict access to certain pages based on the role of the currently logged-in user. This approach makes sure that only authorised users can view or interact with restricted sections of our application.
We’ll cover:
- Setting up Spring Security in a Spring Boot application
- Defining user roles
- Configuring access restrictions for specific pages based on roles
By the end of this post, we will have a working example of role-based access control in a Spring Boot application. This is a post aimed at beginners looking to grow their working knowledge of Spring Boot and Spring Security through hands-on, easy to digest exercises.
This post builds upon my previous two Spring Security blog posts, so if any of the concepts here aren’t entirely clear, I’ll provide links to those previous sections where relevant.
In this post, we’ll focus on the new concepts around role-based access control and assume familiarity with the basics of Spring Security setup and configuration.
Most of what we’ll cover in this post centres on our security configuration and automated tests.
Fortunately, there aren’t too many changes required in the automated tests this time around compared to what we had to cover previously. This means we’ll get to build on the muscle memory we’ve already developed, making the testing process more familiar and streamlined.
Our focus will be:
- Adjusting the security configuration to handle role-based restrictions
- Writing and updating automated tests to validate these restrictions
Project Setup
As always, our project starts with Spring Initializr, where we’ll add three essential dependencies: Spring Boot DevTools, Spring Web, and Spring Security.
A quick reminder: adding Spring Security as a dependency means that, by default, Spring restricts access to all endpoints. This default configuration automatically secures each new endpoint, making it inaccessible to unauthenticated users unless explicitly configured otherwise.
For this post, we won’t revisit tests with Spring Security disabled, unlike last time. This means our focus remains entirely on role-based access with security fully enabled from the outset.
Here’s the pom.xml
for completeness:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>uk.co.a6software</groupId>
<artifactId>spring-security-roles-exploration</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring-security-roles-exploration</name>
<description>Small project to better understand Spring Security roles with JSON API</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Code language: HTML, XML (xml)
Adding The Controller
To get started, we’ll tackle the easiest part of the setup: creating a new controller.
First, create a controllers
package, and within that package, add a new class, which we’ll name ExampleEndpointsController
.
You’re welcome to name this class and any paths in a way that best suits your project.
package uk.co.a6software.spring_security_roles_exploration.controllers;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ExampleEndpointsController {
@GetMapping("/")
public String everyoneEndpoint()
{
return "everyone can see this";
}
@GetMapping("/only-authenticated")
public String userEndpoint()
{
return "only authenticated users can see this";
}
@GetMapping("/only-authorised")
public String authorisedAccessEndpoint()
{
return "only users with an allowed role can see this";
}
@GetMapping("/admin")
public String adminEndpoint()
{
return "admin only";
}
}
Code language: Java (java)
In this example, we define a basic set of routes:
- Public Route
This route is accessible to everyone, whether logged in or not. - Authenticated Route (
/only-authenticated
)
This path is restricted to users who are authenticated in the system, regardless of their role. Any user who has logged in successfully will have access. - Authorised Route (
/only-authorised
)
This path is limited to users with a specific role (ROLE_AUTHORISED
). Only users with that designated role can access this endpoint. - Admin Route (
/admin
)
This route is exclusively available to users with theadmin
role.
Spring Security Locked Down By Default
At this point, you can try starting the server and sending requests to any of these routes.
However, as mentioned earlier, you’ll encounter a 401 Unauthorized
error. This is because, by default, Spring Security locks down all endpoints unless accessed with valid credentials.
When you start up the Spring Boot application, the console will display the default generated credentials. Use these credentials to authenticate requests and access the routes accordingly. Without them, all endpoints remain secured by Spring Security’s default behaviour.
Remember, the password changes every time the application is started. Don’t use the one in the screenshot above!
Basic Spring Security Configuration
Rather than spend time writing automated tests at this stage, or even manually testing this configuration, I’m going to suggest we continue on and create a basic Spring Security configuration that will form the basis of the rest of this post.
We’ll cover:
- Defining user roles within the configuration
- Customising route accessibility based on these roles
With this configuration in place, we’ll have direct control over who can access which parts of the application, based on their user roles.
Here’s the initial configuration:
package uk.co.a6software.spring_security_roles_exploration.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
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.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SpringSecurityConfiguration {
@Bean
public SecurityFilterChain appSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers("/").permitAll()
.anyRequest().denyAll()
)
.httpBasic(Customizer.withDefaults())
.build();
}
@Bean
public UserDetailsService userDetailsService() {
User.UserBuilder builder = User.withDefaultPasswordEncoder();
UserDetails authenticatedUser = builder
.username("authenticated")
.password("authenticated")
.build();
return new InMemoryUserDetailsManager(authenticatedUser, authorisedUser, adminUser);
}
}
Code language: Java (java)
In the Spring Security configuration class, we’ll start by defining some essential beans, beginning with the SecurityFilterChain
. This is the configuration that manages access control and authentication within this application.
To set up the security filter chain, the first step is defining a dedicated bean for it. The method name you choose is important, as Spring seems to skip the configuration if the name conflicts with other defaults. For clarity and guaranteed uniqueness, we’ll name this method appSecurityFilterChain
.
@Bean
public SecurityFilterChain appSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers("/").permitAll()
.anyRequest().denyAll()
)
.httpBasic(Customizer.withDefaults())
.build();
}
Code language: Java (java)
Next, we’ll configure access control. In this example, we’ll permit unrestricted access to the root (/
) route, allowing all users to view it without needing to authenticate.
However, all other routes will be restricted by default, ensuring that access is locked down unless we explicitly define that it isn’t.
@Bean
public SecurityFilterChain appSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers("/").permitAll()
.anyRequest().denyAll()
)
.httpBasic(Customizer.withDefaults())
.build();
}
Code language: Java (java)
To secure these routes, we’ll use HTTP Basic authentication. This approach is straightforward, enabling users to authenticate with their credentials to access restricted sections of the application. It’s a simple and useful for these blog posts, but I wouldn’t use it out there in the real world.
@Bean
public SecurityFilterChain appSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers("/").permitAll()
.anyRequest().denyAll()
)
.httpBasic(Customizer.withDefaults())
.build();
}
Code language: Java (java)
Finally, we’ll make sure to build the security filter chain by calling .build()
at the end of the configuration method. This step is essential for returning a fully constructed security filter chain that Spring can apply to incoming requests.
User Details
The other bean we’ll need is the UserDetailsService
.
Actually, let me rephrase that.
The other bean that makes our life easier if it is defined is one that returns a UserDetailsService
.
We can completely omit this method, and that would start up the application with a user with the username of “user
“, and the long and unwieldy auto-generated password:
For the sake of simplicity, we will define a UserDetailsService
straight away.
Since we covered this in more detail previously, we’ll approach this as a relatively simple code addition.
@Bean
public UserDetailsService userDetailsService() {
User.UserBuilder builder = User.withDefaultPasswordEncoder();
UserDetails authenticatedUser = builder
.username("authenticated")
.password("authenticated")
.build();
return new InMemoryUserDetailsManager(authenticatedUser);
}
Code language: Java (java)
Using UserBuilder
, we’ll create users with the default password encoder.
While this encoder is deprecated, we’ve already discussed why it’s safe to use here for simplicity, despite IDE warnings. The deprecation serves mainly as a reminder that the default encoder isn’t recommended for production environments due to security concerns. Again, see this link.
In this setup, we’ll define a single user named “authenticated
” with the password “authenticated
“.
You’re free to choose any username and password you like.
This example user won’t have any roles assigned.
@Bean
public SecurityFilterChain appSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers("/").permitAll()
.anyRequest().denyAll()
)
.httpBasic(Customizer.withDefaults())
.build();
}
Code language: Java (java)
That’s intentional—accessing the root (/
) path in our configuration simply requires the user to be logged in, without needing any specific role.
This user will be stored in an in-memory UserDetailsManager
which is fine for our exploratory learning, but likely wouldn’t be sufficient in most real world applications.
Manual Testing
One useful feature of IntelliJ IDEA is the HTTP Requests Scratch File, which enables you to manage and test HTTP requests directly within the IDE.
###
GET http://localhost:8080/
###
GET http://localhost:8080/anything
###
GET http://localhost:8080/
Authorization: Basic authenticated authenticated
###
GET http://localhost:8080/anything
Authorization: Basic authenticated authenticated
Code language: PHP (php)
I’ve included a full list of all the endpoints we’ll be working with in this scratch file. You can click on any endpoint to send individual requests, allowing you to quickly test the output of each call.
You don’t need to manually copy these endpoints unless you want a custom setup; the scratch file is ready to use as is. If you’ve modified any usernames, passwords, or route names, remember to update them in the scratch file to reflect your changes.
The scratch file approach is alright for doing some ad-hoc manual testing, but in reality we really ought to be relying on more robust automated tests for this kind of work.
Adding The Automated Test Suite
As mentioned at the start, the tests we’re writing today are nearly identical to those we’ve covered before. This repetition is beneficial because it helps build muscle memory for writing tests in Spring Boot applications, making it easier and more efficient to develop reliable test coverage in the future.
There are a few things to be mindful of today.
In recent editions of IntelliJ, the new LLM (Language Learning Model) integration is likely enabled by default, which can automatically suggest full-line auto-completions as you type. While this feature is incredibly useful if you’re already comfortable with the tests and familiar with writing them, it can be less helpful if, like me, you’re learning and prefer to type everything out manually.
Fortunately, it’s easy to disable this feature temporarily.
Simply go to Settings, then navigate to the Editor section. Under General, find Inline Completion and uncheck Enable local full-line completion suggestions.
Once disabled, IntelliJ will stop suggesting full-line autocompletions. When you’re finished with exercises like this, you’ll probably want to re-enable it, as it can be extremely helpful in the real world.
Testing With Mock Users
Another important point for today’s tests is that we’ll need to create mock users, as we’ve done before. However, for these tests, some of those users will need specific roles to properly validate access control.
To achieve this, we’ll use the @WithMockUser
annotation, ensuring that each mock user is instantiated with the correct roles. Pay close attention to how @WithMockUser
is set up in each test, as it’s essential for simulating users with distinct roles.
Below, I’ve highlighted the relevant lines to show this in action. Don’t miss the import
on line 7:
package uk.co.a6software.spring_security_roles_exploration.controllers;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
@SpringBootTest
@AutoConfigureMockMvc
class ExampleEndpointsControllerTest {
@Autowired
MockMvc mockMvc;
@Test
void everyoneEndpoint() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.get("/"))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().string("everyone can see this"));
}
@Test
void authenticatedEndpointWhenLoggedOutIsNotAccessible() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.get("/only-authenticated"))
.andExpect(MockMvcResultMatchers.status().isUnauthorized());
}
@Test
@WithMockUser(username = "authenticated")
void authenticatedEndpointWhenLoggedInIsAccessible() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.get("/only-authenticated"))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().string("only authenticated users can see this"));
}
@Test
@WithMockUser(username = "some user without ROLE_AUTHORISED")
void authorisedAccessEndpointWithWrongRoleIsNotAccessible() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.get("/only-authorised"))
.andExpect(MockMvcResultMatchers.status().isForbidden());
}
@Test
@WithMockUser(username = "someone", roles = "AUTHORISED")
void authorisedAccessEndpointWithRightRoleIsAccessible() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.get("/only-authorised"))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().string("only users with an allowed role can see this"));
}
@Test
void adminEndpointWhenLoggedOutIsNotAccessible() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.get("/admin"))
.andExpect(MockMvcResultMatchers.status().isUnauthorized());
}
@Test
@WithMockUser(username = "someone", roles = "AUTHORISED")
void adminEndpointWhenLoggedInWithWrongRoleIsNotAccessible() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.get("/admin"))
.andExpect(MockMvcResultMatchers.status().isForbidden());
}
@Test
@WithMockUser(username = "fake admin", roles = "ADMIN")
void adminEndpointWhenLoggedInWithRoleAdminIsAccessible() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.get("/admin"))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.content().string("admin only"));
}
}
Code language: Java (java)
With those tests in place, the remaining work to make them pass is not significantly more complex than what we’ve already implemented.
Our basic authorised requests chain is already set up, allowing all users—whether logged in or not—to access the root (/
) URL, while denying access to any other request by default.
Now, we’re going to define additional routes within this configuration.
@Bean
public SecurityFilterChain appSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authorizeRequests ->
authorizeRequests
.requestMatchers("/").permitAll()
.requestMatchers("/only-authenticated").authenticated()
.requestMatchers("/only-authorised").hasAnyRole("ADMIN", "AUTHORISED")
.requestMatchers("/admin").hasRole("ADMIN")
.anyRequest().denyAll()
)
.httpBasic(Customizer.withDefaults())
.build();
}
Code language: Java (java)
First, we’ll add a route at /only-authenticated
, which will allow access only to users who are authenticated, regardless of their role.
Next, we’ll set up a route at /only-authorised
. This path will require the user not only to be authenticated but also to have a specific role.
In this case, the required roles will be either “admin” or “authorised.”
It’s important to note that in Spring Security, roles are typically prefixed with ROLE_
, such as ROLE_ADMIN
or ROLE_AUTHORIZED
. However, when specifying roles in the configuration, you don’t need to include the ROLE_
prefix—Spring Security will handle it automatically.
Finally, we’ll add a request matcher for the /admin
route. This route will be accessible only to authenticated users with the specific role of “admin.”
Here, we’re using hasRole
, which accepts a single role, as opposed to hasAnyRole
, which allows one or more roles.
Adding The Extra Users
Lastly we need to create additional users with the required roles.
@Bean
public UserDetailsService userDetailsService() {
User.UserBuilder builder = User.withDefaultPasswordEncoder();
UserDetails authenticatedUser = builder
.username("authenticated")
.password("authenticated")
.build();
UserDetails authorisedUser = builder
.username("authorised")
.password("authorised")
.roles("AUTHORISED")
.build();
UserDetails adminUser = builder
.username("admin")
.password("admin")
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(authenticatedUser, authorisedUser, adminUser);
}
Code language: Java (java)
First, we set up an authorised user with the username and password both set to authorised
, and assign them the role of authorised
.
Next, we’ll create an admin user with the username, password, and role all set to admin
.
Be sure to add these users to the InMemoryUserDetailsManager
.
And with that, we should be ready to go. This setup will allow us to test each route against users with the correct roles to ensure our access restrictions are functioning as expected.
Manual HTTP Scratch Entries
Here are all the HTTP scratches for easy reference:
###
GET http://localhost:8080/
###
GET http://localhost:8080/anything
###
GET http://localhost:8080/only-authenticated
###
GET http://localhost:8080/only-authorised
###
GET http://localhost:8080/admin
###
GET http://localhost:8080/only-authenticated
Authorization: Basic authenticated authenticated
###
GET http://localhost:8080/only-authenticated
Authorization: Basic authorised authorised
###
GET http://localhost:8080/only-authenticated
Authorization: Basic admin admin
###
GET http://localhost:8080/only-authorised
Authorization: Basic authenticated authenticated
###
GET http://localhost:8080/only-authorised
Authorization: Basic authorised authorised
###
GET http://localhost:8080/only-authorised
Authorization: Basic admin admin
###
GET http://localhost:8080/admin
Authorization: Basic authenticated authenticated
###
GET http://localhost:8080/admin
Authorization: Basic authorised authorised
###
GET http://localhost:8080/admin
Authorization: Basic admin admin
Code language: PHP (php)
Wrapping Up
At this point, all our tests should be passing, whether you’re running them through the automated test suite or manually using the HTTP scratch feature.
This particular exercise has been simpler than the previous two posts, largely because it builds on what we’ve already set up. The requirements this time were relatively straightforward.
Ultimately, my goal is to create a system that enables team-based logins. For instance, this would allow for a team manager user with administrative privileges and team members with more limited permissions—much like a business-to-business SaaS model. However, to reach that level of functionality, I want to ensure I fully understand each component and how it integrates into the broader system.
There’s still quite a bit to learn, but each of these exercises brings me closer to a comprehensive, role-based access control setup suitable for a multi-user, team-oriented environment.