- Published on
Spring Security by Default in a Spring Boot API
- Authors
- Name
- Luis Carbonel
In this article, we will explore how Spring Security transforms a Spring Boot API from the ground up. We'll discover the principles of the security by default philosophy that Spring Security brings with it and how these fundamentals provide a robust layer of protection for your application from day one.
- Creating an Insecure API
- Consequences of an insecure API
- API request from a non-existing endpoint
- Spring Security by default
- Add Spring Security to our application
- Authentication required for all requests
- Secure headers for all requests
- Protects against CSRF attacks
- Conclusion
Creating an Insecure API
To start, we are going to create a simple Spring Boot API (representing a task API) that does not have any security. This will help us understand how Spring Security protects us by default and how important it is to have a security layer in our application.
First of all, go to Spring Initializr and create a new project with the following settings:
Project Setup:
Dependencies
- Gradle Project (Kotlin)
- Java 17
- Spring Web
- Spring Data JDBC
- H2 Database
gradle.build
plugins {
java
id("org.springframework.boot") version "3.1.5"
id("io.spring.dependency-management") version "1.1.3"
}
group = "com.devlach"
version = "0.0.1-SNAPSHOT"
java {
sourceCompatibility = JavaVersion.VERSION_17
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
implementation("org.springframework.boot:spring-boot-starter-web")
runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<Test> {
useJUnitPlatform()
}
tasks.bootBuildImage {
builder.set("paketobuildpacks/builder-jammy-base:latest")
}
Data Model
Now, let's create a simple entity to represent our tasks. We will use Spring Data JDBC, which does not require our entities to be annotated with @Entity. Here is the definition of the Task entity:
package com.devlach.springbootsecurebydefault;
import org.springframework.data.annotation.Id;
public record Task(@Id Long id, String description, boolean completed, String owner) {
public Task(String description, boolean completed, String owner) {
this(null, description, completed, owner);
}
}
Repository and Controller
Next, we will create a repository and a controller to handle the operations with our tasks:
package com.devlach.springbootsecurebydefault;
import org.springframework.data.repository.CrudRepository;
public interface TaskRepository extends CrudRepository<Task, Long> {
}
package com.devlach.springbootsecurebydefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.util.UriComponentsBuilder;
@RestController
@RequestMapping("/tasks")
public class TaskController {
private final TaskRepository taskRepository;
public TaskController(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
@PostMapping
public ResponseEntity<Task> createTask(@RequestBody Task task, UriComponentsBuilder ubc) {
var taskSaved = taskRepository.save(task);
var urlLocationOfNewTask = ubc.path("/tasks/{id}").buildAndExpand(taskSaved.id()).toUri();
return ResponseEntity.created(urlLocationOfNewTask).body(taskSaved);
}
@GetMapping("/{taskId}")
public ResponseEntity<Task> findById(@PathVariable Long taskId) {
return taskRepository.findById(taskId)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping
public ResponseEntity<Iterable<Task>> findAll() {
return ResponseEntity.ok(taskRepository.findAll());
}
}
Now that we have our API, we can run it and test it. But first, let's create some dummy data in our H2 database.
Go to src/main/resources
and create the following files:
schema.sql
CREATE TABLE IF NOT EXISTS task
(
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
description VARCHAR(255) NOT NULL,
completed BOOLEAN NOT NULL,
owner VARCHAR(255) NOT NULL
);
data.sql
INSERT INTO task (description, completed, owner)
VALUES ('Task 1', false, 'carmine'),
('Task 2', true, 'maria'),
('Task 3', false, 'carbonel');
These files will be executed when the application starts and will create the table and insert the data.
Now, we can run our application and test it with HTTPie.
- HTTPie is a command-line HTTP client for the modern HTTP API, with intuitive command syntax, support for JSON, chaining, forms and files, syntax highlighting, session persistence, paging, authentication, and more.
To run our application, open a terminal in the root of the project and run the following command:
$ ./gradlew bootRun
Now, we can use HTTPie to interact with our API.
Let's make a request to get all the tasks:
http :8080/tasks
You should get a response like this one:
[
{
"completed": false,
"description": "Task 1",
"id": 1,
"owner": "carmine"
},
{
"completed": true,
"description": "Task 2",
"id": 2,
"owner": "maria"
},
{
"completed": false,
"description": "Task 3",
"id": 3,
"owner": "carbonel"
}
]
As we have noticed, our application works, but it is not secure. So what is the problem with our API?
Consequences of an insecure API
Not protecting our API can have serious consequences. Let's take a look at some of them:
- The information is public: There is no control over who accesses the data.
- Identity is anonymous: There is no way to know who is making the request.
- Information is unprotected: Malicious actors can exploit browser-based vulnerabilities.
Public Information
Because the content is public, any user with access to the network location can make requests to the API and view the data.
While it is possible to mitigate this to some extent with network security, in practice, many REST APIs are often exposed to the public through browsers or API gateways.
Even if they were not exposed to the public on the Internet, the very real threat of server-side request forgery SSRF should raise concern if we are contemplating leaving any of our production APIs unprotected in this way.
Anonymous Identity
Because the identity is anonymous, it is impossible to determine whether the user is known, trusted and authorized. Practically, it is also more complicated to present user-specific content, since the user's identifier is not included in the request.
On the other hand, the user experience is poor, as it is not possible to customize the content for the user. The right thing is that the user can see only his tasks, but in this case, all users can see all tasks.
Unprotected Information
And because the information is unprotected, when this REST API is exposed to a browser, it can leave the application as a whole vulnerable to CSRF, MITM, XSS and other attacks.
From a security standpoint, this application cannot authenticate or authorize requests, nor can it mitigate common security vulnerabilities.
API request from a non-existing endpoint
If we make a request to an endpoint that does not exist, what should happen?
Go to the terminal and make a request to a non-existing endpoint like this:
http :8080/does-not-exist
We should get a 404 Not Found
:
{
"error": "Not Found",
"path": "/does-not-exist",
"status": 404,
"timestamp": "..."
}
You may be asking yourself, is this a security problem?
The answer is yes, since an attacker can use this information to identify endpoints and exploit vulnerabilities.
Up to this point, we have seen that our API is not secure, our content is public, the identity is anonymous, and the information is unprotected.
In the next section, we will see how Spring Security protects us by default.
Spring Security by default
When we add Spring Security to our application, we get a default security layer that protects us by default. Let's see what Spring Security provides by default.
- Authentication required for all requests.
- Security headers for all requests.
- Provides basic authentication with a default user.
- Contains an application firewall.
- Protects against CSRF attacks.
- Responds RESTfully to security errors.
Well, it's time to write some code. We are going to add Spring Security to our application, and we will understand how with the configuration we can obtain very attractive premeditated security behaviors.
Add Spring Security to our application
To add Spring Security to our application, we will add the spring-boot-starter-security
dependency to our build.gradle.kts
file:
dependencies {
...
implementation("org.springframework.boot:spring-boot-starter-security")
...
}
With this Spring Security is added to our application, and it will be automatically detected by Spring Boot.
Once this is done, let's restart our application and make a request to the API:
http :8080/tasks
You should get a 401 Unauthorized
response:
HTTP/1.1 401
...
This is because Spring Security requires authentication for all requests.
Authentication required for all requests
Next, we will create a test to create a task to check that all requests require authentication.
First we will add an update to our build.gradle.kts
file that will be useful for our test and its terminal output:
...
// Useful for outputting test results to the terminal
tasks.test {
testLogging {
events("passed", "skipped", "failed")
showExceptions = true
showCauses = true
showStackTraces = true
exceptionFormat = TestExceptionFormat.FULL
// For verbose output change to true
showStandardStreams = false
}
}
...
Go to the SpringBootSecureByDefaultApplicationTests
class and add the following test:
package com.devlach.springbootsecurebydefault;
import org.junit.jupiter.api.DisplayName;
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.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class SpringBootSecureByDefaultApplicationTests {
@Autowired
private MockMvc mockMvc;
@Test
@DisplayName("POST /tasks should return 201 Created")
@DirtiesContext // This annotation is needed to reset the database after each test
void postTasks() throws Exception {
mockMvc.perform(post("/tasks")
.contentType("application/json")
.content("""
{
"description": "Learn Spring Security",
"completed": false,
"owner": "devlach"
}
"""))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andReturn().getResponse().getHeader("Location");
}
}
Running the test
To run the test, open a terminal in the root of the project and run the following command:
./gradlew test
You should get an output like this:
> Task :test FAILED
SpringBootSecureByDefaultApplicationTests > POST /tasks should return 201 Created FAILED
java.lang.AssertionError: Status expected:<201> but was:<403>
...
1 test completed, 1 failed
As we can see, the test fails because Spring Security requires authentication for all requests. But why does it fail with a 403 Forbidden
and not a 401 Unauthorized
?. Umm, keep reading to find out.
Now, let's add a default user to be able to authenticate and pass the test.
Basic authentication in our test
To provide authentication to our test, we will add the spring-security-test
dependency to our build.gradle.kts
file:
dependencies {
...
testImplementation("org.springframework.security:spring-security-test")
...
}
Now we will use @WithMockUser
to simulate an authenticated user in our test. This will tell Spring to simulate a user with username user for each test.
...
import org.springframework.security.test.context.support.WithMockUser;
...
@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser // This annotation is needed to simulate an authenticated user
class SpringBootSecureByDefaultApplicationTests {
...
}
Now let's run the test again:
./gradlew test
> Task :test FAILED
SpringBootSecureByDefaultApplicationTests > POST /tasks should return 201 Created FAILED
java.lang.AssertionError: Status expected:<201> but was:<403>
...
1 test completed, 1 failed
But what happened?. The test still fails, but now what is the problem?.
The problem here is that Spring Security does not authorize a POST request without a CSRF token.
To solve this, we are going to add a CSRF token to our test by making use of the MockMvc
RequestPostProcessor
declaration and use the with(csrf())
method:
import static org.springframework.security.test.web.servlet.request
.SecurityMockMvcRequestPostProcessors.csrf;
...
void postTasks() throws Exception {
mockMvc.perform(post("/tasks")
.contentType("application/json")
.with(csrf()) // This is needed to pass the CSRF check
...
Run the test again:
./gradlew test
> Task :test
SpringBootSecureByDefaultApplicationTests
> POST /tasks should return 201 Created PASSED
...
And now, the test was successful 🥳.
What we have seen so far is that Spring Security requires authentication for all requests, and it also requires CSRF for side effect operations (POST, PUT, PATCH, DELETE).
Secure headers for all requests
Just as Spring Security requires authentication for all requests, it also adds security headers to strengthen API protection. Try to access the API without authentication again with HTTPie:
http :8080/tasks
The response should include the following security headers:
HTTP/1.1 401
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Connection: keep-alive
Content-Length: 0
Date: ...
Expires: 0
Keep-Alive: timeout=60
Pragma: no-cache
Set-Cookie: JSESSIONID=78374A3928DF3DC45AEC5FD7C6676FC9; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm"
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-XSS-Protection: 0
Security headers analysis:
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
- Disables caching to ensure that information does not remain on the device of the user.
Pragma: no-cache
- It Indicates that the response should not be cached.
Set-Cookie:
- Set a session cookie for the user.
WWW-Authenticate:
- It Indicates that authentication is required to access the API.
X-Content-Type-Options: nosniff
- It Indicates that the MIME type of the response should not be altered.
X-Frame-Options:
- Denies the possibility of being included in a
<frame>
,<iframe>
,<embed>
or<object>
, protecting against clickjacking attacks.
- Denies the possibility of being included in a
X-XSS-Protection:
- It Indicates that the response should not be scanned for XSS attacks.
In summary, the automatic security proposal offered by Spring Security is very attractive.
Basic authentication with a default user
Spring Security also provides a default user to authenticate us in our application. This user is user
and its password is a random value generated when starting the application.
To get the password of the default user, we can see it in the terminal output when we start the application:
...
Using generated security password: 7ac4fdfe-e165-4bcc-97e8-4b3fa152165b
...
Now, we can use this password to authenticate in our application:
http -a user:7ac4fdfe-e165-4bcc-97e8-4b3fa152165b :8080/tasks
You should get a 200
response:
HTTP/1.1 200
...
[
{
"completed": false,
"description": "Task 1",
"id": 1,
"owner": "carmine"
},
{
"completed": true,
"description": "Task 2",
"id": 2,
"owner": "maria"
},
{
"completed": false,
"description": "Task 3",
"id": 3,
"owner": "carbonel"
}
]
Testing a non-existing endpoint
Now, if we make a request to an endpoint that does not exist, what should happen?
http -a user:7ac4fdfe-e165-4bcc-97e8-4b3fa152165b :8080/does-not-exist
Now, instead of getting a 401 Unauthorized
, we get a 404 Not Found
:
HTTP/1.1 404
...
{
"error": "Not Found",
"path": "/does-not-exist",
"status": 404,
"timestamp": "2023-11-16T21:30:00.507+00:00"
}
The 404 Not Found
is a more appropriate response for an endpoint that does not exist for an authenticated user.
Protects against CSRF attacks
Now we will create a task with an authenticated user:
http -a user:7ac4fdfe-e165-4bcc-97e8-4b3fa152165b :8080/tasks \
"Accept:application/json" \
description="Learn Spring Security" \
completed=false \
owner="devlach"
Although the user is authenticated, Spring denies the request with a:
HTTP/1.1 401
...
This is because Spring Security protects against CSRF attacks by default. Remember that to solve this in our test, we had to add a CSRF token in our test using MockMvc RequestPostProcessor and use the with(csrf())
method.
Contains an application firewall
Hypothetically, Spring Security could be configured to require administrative rights to request the /admin/...
endpoints, and any authenticated user can request everything else. endpoints, and any authenticated user can request everything else. In that case, a user would not be able to request the /admin endpoint.
http -a user:7ac4fdfe-e165-4bcc-97e8-4b3fa152165b :8080/admin
HTTP/1.1 404
No surprise here, since we have not configured any /admin
endpoint. It would be the same behavior if we try to access an endpoint that does not exist.
But what if we try to guess a more nested administrative path?
http -a user:7ac4fdfe-e165-4bcc-97e8-4b3fa152165b :8080/admin%2Fusers
Here a bad actor could guess a more nested administrative path, if you notice the %2F
character is an escape character for /
, with this the attacker tries to access a /admin/users
endpoint. Fortunately, Spring Security protects us from this:
HTTP/1.1 400
...
Conclusion
In this article, we discover the principles of the "security by default" philosophy that Spring Security brings to the table and how these fundamentals provide a robust layer of protection for your application from day one. We also note how Spring Security requires authentication for every endpoint by default, requires CSRF for side effect operations, regenerates the user's default password on startup, writes secure headers in responses, and blocks many types of malicious requests.
All the code snippets mentioned in the article can be found on GitHub.