Discover Cloud Solutions with HostingerGet a special discount.

hostingerLearn More
Published on

Spring Security by Default in a Spring Boot API

Authors
  • avatar
    Name
    Luis Carbonel
    Twitter
Spring Security by Default in a Spring Boot API

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

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.
  • 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.