Post Thumbnail

Spring Boot API Key Authentication

1. Overview

In this tutorial, we will look at how to use an API key to secure a Spring Boot application endpoint. It is not uncommon for publicly available APIs to require that a key is passed via the header before making a request.

This serves as a means of protecting the API from unwanted access and also help identify the current user calling the API.

For this article, we will build a simple Spring Boot application that exposes an API for getting Chuck Norris facts and secure it with an API key.

2. Application Setup

We will use Spring Initializer to generate a Spring Boot application. The application will use Spring Boot version 3.2.2 and JDK 21.

You can download the application via this link. The complete source code will be made available at the of the article.

3. Creating the Quote Service

Let’s begin by creating a QuoteService in the services package. It will have only one method called chuckNorrisFacts(). This function will use the Faker library to generate a random Chuck Norris fact and return it.

Listing 3.1 QuoteService.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Service
public class QuoteService {

    public Response<Map<String, Object>> chuckNorrisFacts() {
        var quote = Faker.instance().chuckNorris().fact();
        return Response.successfulResponse("Operation Successful",
                Map.of("quote", quote));
    }

}

We will create a corresponding QuoteController that will expose the quotes endpoint and delegate to the QuoteService we created earlier.

Listing 3.2 QuoteController.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@RestController
public class QuoteController {
    private final QuoteService quoteService;

    public QuoteController(QuoteService quoteService) {
        this.quoteService = quoteService;
    }

    @GetMapping(Routes.QUOTES)
    public ResponseEntity<Response<Map<String, Object>>> famousQuotes() {
        var response = quoteService.chuckNorrisFacts();
        return ResponseEntity.status(response.getHttpStatus())
                .body(response);
    }
    
}

At this point, we can start the application and curl the http://localhost:8080/quotes endpoints, without any API key.

The Spring Boot application will not require any form of authentication yet because we have yet to add the spring-boot-starter-security dependency.

Listing 3.3 Curl Request

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
curl -X GET --location "http://127.0.0.1:8080/quotes"

{
  "code": 200,
  "message": "Operation Successful",
  "success": true,
  "data": {
    "quote": "Chuck Norris doesn't program with a keyboard. He stares the computer down until it does what he wants."
  }
}

4. Spring Security Configuration

Spring Security is the library that contains the security-related features in the Spring ecosystem. Let’s add its dependency:

Listing 4.1 pom.xml

1
2
3
4
<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-security</artifactId>
</dependency>

The version is controlled by the parent Spring Boot BOM. So we need not explicitly add a version tag.

Spring security works by a series of filters that check every request and based on the attributes present in the request it determines what type of authentication mechanism to use.

At the end of all the filters, Spring will check the Authentication object in the SecurityContextHolder. If the isAuthenticated() method of the Authentication returns true, then it will treat the request as duly authenticated. Otherwise, it’ll return an unauthorized error.

SecurityFilterChain is the name of the class that coordinates the flow of a request through all the Filters. Therefore, we will need to create our own Filter and add it to the SecurityFilterChain.

This way, for every new request, our Filter will be called, and we can check the incoming API key in the request header.

If it’s valid, we will signal to Spring Security that this request is authenticated. If it’s not valid, we will return a 401 unauthorized error which will prevent the request from reaching the controller.

Let’s start by creating a helper class that will fetch the ClientCredential from the database using the request’s API key. If found, we will consider the apiKey valid:

Listing 4.2 ClientAuthenticationHelper.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Service
public class ClientAuthenticationHelper {

    private final ClientCredentialRepository clientCredentialRepository;

    public ClientAuthenticationHelper(ClientCredentialRepository clientCredentialRepository) {
        this.clientCredentialRepository = clientCredentialRepository;
    }

    public boolean validateApiKey(String requestApiKey) {
        //this is a simplistic implementation. Prod
        //implementation will check for expired key and other business logic
        var optionalClientCred = clientCredentialRepository.findByApiKey(requestApiKey);
        return optionalClientCred.isPresent();
    }

}

The logic in the helper class can be as complex as necessary and will vary based on the business requirement.

Next, we will create a custom Filter class:

Listing 4.3 ApiKeyFilter.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class ApiKeyFilter extends OncePerRequestFilter {

    private final ClientAuthenticationHelper authServiceHelper;

    public ApiKeyFilter(ClientAuthenticationHelper authServiceHelper) {
        this.authServiceHelper = authServiceHelper;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws
            ServletException, IOException {

        String reqApiKey = request.getHeader("Api-Key");
        boolean isApiKeyValid = authServiceHelper.validateApiKey(reqApiKey);

        if(!isApiKeyValid) {
            //return 401 Unauthorized
            response.sendError(HttpStatus.UNAUTHORIZED.value(), "Invalid API Key");
            return;
        }

        //apiKey is valid. Signal to Spring Security, this is an authenticated request
        var authenticationToken = new UsernamePasswordAuthenticationToken(reqApiKey,
                reqApiKey, Collections.emptyList());
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);

        //continue process the request
        filterChain.doFilter(request, response);

    }
}

We read the Api-Key associated with the request and validate it with the help of the ClientAuthenticationHelper. If the apiKey is invalid, we return a 401 error. Otherwise, we simply signal to Spring Security that this request is authenticated.

The signalling involves creating a new instance of the UsernamePasswordAuthenticationToken. We then update the SecurityContextHolder to use the newly created authentication token via SecurityContextHolder.getContext().setAuthentication();.

This act of creating an authentication token and attaching it to the security context holder is what it means to log-in, in a Spring Boot application.

It’s very important to use the constructor with three args, as it’s the only one that sets the authenticated field as true.

Listing 4.4 UsernamePasswordAuthenticationToken.java

1
2
3
4
5
6
7
public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
		Collection<? extends GrantedAuthority> authorities) {
	super(authorities);
	this.principal = principal;
	this.credentials = credentials;
	super.setAuthenticated(true); // must use super, as we override
}

Note that the UsernamePasswordAuthenticationToken is part of the Spring security library. The fully qualified class name is org.springframework.security.authentication.UsernamePasswordAuthenticationToken#UsernamePasswordAuthenticationToken.

The last part is to plug our classes into the Spring Security chain. Since this is SpringBoot v3+, we will do so by creating a SecurityFilterChain bean that will host our custom configuration.

Listing 4.5 WebSecurityConfig.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Configuration
public class WebSecurityConfig {

    private final ClientAuthenticationHelper authServiceHelper;

    public WebSecurityConfig(ClientAuthenticationHelper authServiceHelper) {
        this.authServiceHelper = authServiceHelper;
    }


    @Bean
    public SecurityFilterChain configure(HttpSecurity http) throws Exception {

        //add the ApiKeyFilter to the security chain
        http.addFilterBefore(new ApiKeyFilter(authServiceHelper),
                AnonymousAuthenticationFilter.class);

        //configure the security chain to authenticate all endpoints
        //except the /error
        http.authorizeHttpRequests(requests ->
                requests.requestMatchers(new AntPathRequestMatcher("/error")).permitAll()
                        .anyRequest().authenticated()
        );

        //since this is an API app, configure it to be stateless
        http.sessionManagement(session -> 
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }

}

We configured the SecurityFilterChain to place our ApiKeyFilter before the AnonymousAuthenticationFilter class. This is so that our request will not be marked anonymous, which will trigger a forbidden error.

Now, if we try to invoke the /quotes endpoint with no Api-Key or an invalid one, we will get an exception.

Listing 4.6 Invalid Curl Request

1
2
3
4
5
6
7
8
curl -X GET --location "http://127.0.0.1:8080/quotes"

{
  "timestamp": "2024-01-20T15:53:31.473+00:00",
  "status": 401,
  "error": "Unauthorized",
  "path": "/quotes"
}

However, calling the endpoint with the right API key will return a quote:

Listing 4.7 Valid Curl Request

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
curl -X GET --location "http://127.0.0.1:8080/quotes" \
    -H "Api-Key: 5c8d56a2-88b2-48a7-aec8-c9a8ac7af99a"
    
{
  "code": 200,
  "message": "Operation Successful",
  "success": true,
  "data": {
    "quote": "Chuck Norris hosting is 101% uptime guaranteed."
  }
}

5. Multiple Authentication Mechanism

The beauty of Spring Security is that we can have more than one type of authentication working consecutively in the same application.

Let’s imagine that our application is a monolith and some other endpoints that do not require an API key. Some require the traditional username and password or even a JWT token.

We can configure the ApiKeyFilter to ignore those requests. Thus, allowing different authentication mechanism to work in the same application.

We simply need to add a common prefix to all the endpoints that require an API key. Then we will override the shouldNotFilter() method in ApiFilter to return true for every endpoint that does not begin with the designated prefix.

Listing 5.1 ApiFilter.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class ApiKeyFilter extends OncePerRequestFilter {

    private final ClientAuthenticationHelper authServiceHelper;
    private final ApiKeyFilterConfig config;

    public ApiKeyFilter(ClientAuthenticationHelper authServiceHelper, ApiKeyFilterConfig config) {
        this.authServiceHelper = authServiceHelper;
        this.config = config;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
        var path = request.getRequestURI();
        return !path.startsWith(config.getPathPrefix());
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws
            ServletException, IOException {

        String reqApiKey = request.getHeader("Api-Key");
        boolean isApiKeyValid = authServiceHelper.validateApiKey(reqApiKey);

        if (!isApiKeyValid) {
            //return 401 Unauthorized
            response.sendError(HttpStatus.UNAUTHORIZED.value(), "Invalid API Key");
            return;
        }

        //apiKey is valid. Signal to Spring Security, this is an authenticated request
        var authenticationToken = new UsernamePasswordAuthenticationToken(reqApiKey,
                reqApiKey, Collections.emptyList());
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);

        //continue process the request
        filterChain.doFilter(request, response);

    }
}

In Listing 5.1 above, we fetch the path prefix from a configuration which will allow us to change it with little to no code changes. The prefix is set in the application.properties file to be /api. So, we need to update the quotes endpoint to /api/quotes.

To buttress our points further, we will create another endpoint web/home and configure Spring Security to not require any form of authentication for that.

Listing 5.2 IndexController.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@RestController
public class IndexController {

    @GetMapping(Routes.WEB_INDEX)
    public ResponseEntity<Response<Map<String, Long>>> index() {
        var response = Response.successfulResponse("Welcome to SMATTME",
                Map.of("timestamp", System.currentTimeMillis()));
        return ResponseEntity.status(response.getCode())
                .body(response);
    }
}

Listing 5.3 WebSecurityConfig.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Configuration
public class WebSecurityConfig {

    private final ClientAuthenticationHelper authServiceHelper;
    private final ApiKeyFilterConfig config;

    public WebSecurityConfig(ClientAuthenticationHelper authServiceHelper, ApiKeyFilterConfig config) {
        this.authServiceHelper = authServiceHelper;
        this.config = config;
    }


    @Bean
    public SecurityFilterChain configure(HttpSecurity http) throws Exception {

        //add the ApiKeyFilter to the security chain
        http.addFilterBefore(new ApiKeyFilter(authServiceHelper, config),
                AnonymousAuthenticationFilter.class);

        //configure the security chain to authenticate all endpoints
        //except the /error
        http.authorizeHttpRequests(requests ->
                requests.requestMatchers(new AntPathRequestMatcher("/error")).permitAll()
                        .requestMatchers(new AntPathRequestMatcher(Routes.WEB_INDEX)).permitAll()
                        .anyRequest().authenticated()
        );

        //since this is an API, configure it to be stateless
        http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }

}

On line 24 is where we configured the WEB_INDEX endpoint to not require any form of authentication. This is why when we make an HTTP request to /web/home without an API key, we still get the right response. But if we invoke api/quotes without a valid key, we will get the usual 401 exception.

6. Conclusion

Spring Security is a versatile library that can cater for myriads of use cases and security requirements. In this article, we’ve seen how to use a combination of custom Filter and SecurityFilterChain to achieve securing our API with an API Key.

The approach we considered here is the simple version. There’s another technique that’s more comprehensive than this. We will consider that in another article as a follow-up to this one.

The complete source code is available on GitHub.

Happy Coding!