Post Thumbnail

How to Implement HMAC Signature in Spring Boot for Secure API Requests

1. Overview

Securing APIs is crucial in modern web applications, and HMAC signatures offer a lightweight yet powerful solution.

Hashed Based Message Authentication Code (HMAC) is a cryptography procedure that generates a code from a given data using a secret key and a hash function like SHA-256.

It does not require the API client to send the actual secret, so it protects against sniffing-related attacks.

In this tutorial, we’ll explore how to implement HMAC signature verification in a Spring Boot application, ensuring your API requests are secure and tamper-proof.

2. Project Setup

We will be using Java 21, Spring Boot 3.x, Flyway and PostgreSQL as the database. The project structure is available via this Spring Initialzr link.

3. Base Application Configuration

For the purpose of this article, we will create a Spring Boot web app that exposes an API for transferring money to a beneficiary.

The web app will have two entities: User and UserSecretKey that represents the users and user_secret_keys table respectively.

Each user will have their own secret key record. This will make it easy to manage and rotate keys independent of other Users.

Listing 3.1 User.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
@Table(name = "users")
@Entity
@SQLRestriction("abolished_at IS NULL")
public class User {

    @Id
    @SequenceGenerator(name = "users_id_seq", sequenceName = "users_id_seq", allocationSize = 1)
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "users_id_seq")
    private long id;
    private String email;
    private String password;
    private String firstName;
    private String lastName;

    @CreationTimestamp
    private LocalDateTime createdAt;

    @UpdateTimestamp
    private LocalDateTime updatedAt;

    private LocalDateTime abolishedAt;
    
    //getters and setters omitted
}

Listing 3.2 UserSecretKey.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
@Table(name = "user_secret_keys")
@Entity
@SQLRestriction("abolished_at IS NULL")
public class UserSecretKey {

    @Id
    @SequenceGenerator(name = "user_secret_keys_id_seq", sequenceName = "user_secret_keys_id_seq", allocationSize = 1)
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "user_secret_keys_id_seq")
    private long id;

    @OneToOne
    private User user;

    @Convert(converter = HmacSecretKeyAttributeConverter.class)
    private String hmacSecretKey;

    private String clientId;

    @CreationTimestamp
    private LocalDateTime createdAt;

    @UpdateTimestamp
    private LocalDateTime updatedAt;

    private LocalDateTime abolishedAt;

    //getters and setters omitted
}

We have configured Flyway to run the migration scripts and seed the database with default user email [email protected] and password karibuChangeMe.

The same default email also serves as the default clientId and the we encrypt the default password to create the default hmacSecretKey.

Take note of line 14–15 in Listing 3.2. We added @Convert(converter = HmacSecretKeyAttributeConverter.class) to ensure we do not store persist plain text secrets in the database.

The converter will automatically encrypt the plain HMAC secret key, using AES, when saving to the database and decrypt it when reading.

Now that we have the entities setup, let’s create a service and controller that will expose a fund transfer API.

Listing 3.3 TransferService.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Service
public class TransferService {

    public TransferResponse transferFund(TransferRequest transferRequest) {
        //do the actual debit, and send funds to beneficiary
        //....
        return new TransferResponse(true, HttpStatus.OK.value(),
                "Fund transferred successfully",
                transferRequest.reference());

    }
}

Listing 3.4 TransferController.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@RestController 
public class TransferController {

    private final TransferService transferService;
    
    public TransferController(TransferService transferService) {
        this.transferService = transferService;
    }

    @PostMapping(Routes.TRANSFER)
    public ResponseEntity<TransferResponse> transferFund(TransferRequest transferRequest) {
        var response = transferService.transferFund(transferRequest);
        return ResponseEntity.status(response.statusCode()).body(response);
    }
}

At first, we will only require HTTP Basic Authentication for the routes by configuring HTTP Basic authentication in Spring Security.

We will also set up a UserDetailsService that will allow us to log in with the default username and password we’ve seeded.

Listing 3.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
33
34
35
36
37
38
39
40
41
42
@Configuration
@Slf4j
public class WebSecurityConfig {
    
    private final UserRepository userRepository;

    public WebSecurityConfig(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Bean
    public SecurityFilterChain configure(HttpSecurity http) throws Exception {
        
        //enable default http basic setup
        http.httpBasic(withDefaults());
        http.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        http.csrf(AbstractHttpConfigurer::disable);

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

        );

        return http.build();
    }

    
    @Bean
    public UserDetailsService userDetailsService() {
        return (username) -> {
            User user = userRepository.findByEmail(username);
            if(Objects.isNull(user)) throw new UsernameNotFoundException("User " + username + " not found");
            return new org.springframework.security.core.userdetails.User(user.getEmail(),
                    user.getPassword(), Collections.emptyList());
        };
    }
    
}

At this point, we can invoke the transfer endpoint with HTTP Basic header and it will work.

Listing 3.6 Curl HTTP Request

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22

#request

curl --location 'http://localhost:8080/wallet/transfer' \
--header 'Content-Type: application/json' \
--header 'Authorization: Basic b2x1c29sYS5jeXBoZXJAZXhhbXBsZS5jb206a2FyaWJ1Q2hhbmdlTWU=' \
--data-raw '{
    "recipientAccountNumber": "1234567890",
    "recipientEmail": "[email protected]",
    "sourceAccountNumber": "0987654321",
    "amount": 1000.0,
    "currency": "NGN",
    "reference": "81265ed8-17d7-4125-9c86-834d4f985f47"
}'

#response
{
    "status": true,
    "statusCode": 200,
    "statusMessage": "Fund transferred successfully",
    "reference": "1aa28982-efea-4392-91cc-21eecfb70baa"
}

4. HMAC Signature Validation

Now that we have the application setup, let’s build on it by adding HMAC signature.

We will ask the API clients to concatenate the minified request body and the current utc timestamp in milliseconds to create a payload.

Then they will sign this payload using HMAC-SHA256 and the secret key we’ve shared with them. They will then add the resulting signature, timestamp, and clientId to the request headers.

Any request to the API without any of the required headers will get Unauthorized error.

We require the timestamp to be part of the HMAC signature to prevent replay attacks. When we get a request, we will compute the time difference in milliseconds.

If the difference is more than 60,000 (that is, 1 minute), we will reject the request as expired.

Because we are using Basic Auth in this tutorial, the Client will need to send the Basic Auth headers and the HMAC related headers.

Now that we understand what we want to build, let’s start building by creating a method that can sign a given payload using HMAC.

We will reuse the same one from a previous HMAC tutorial.

Listing 4.1 HmacHelper.java

1
2
3
4
5
6
7
public static String generateHMACSignature(String message, String secret) throws Exception {
    Mac hmacSHA256 = Mac.getInstance("HmacSHA256");
    SecretKeySpec secretKeySpec = new SecretKeySpec(secret.getBytes(), "HmacSHA256");
    hmacSHA256.init(secretKeySpec);
    byte[] signatureBytes = hmacSHA256.doFinal(message.getBytes());
    return Base64.getEncoder().encodeToString(signatureBytes);
}

Next, we will create a Filter to intercept every request and perform the signature validation:

Listing 4.2 HmacSignatureFilter.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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
@Component
@Slf4j
public class HmacSignatureFilter extends OncePerRequestFilter {

    private final UserSecretKeyRepository userSecretKeyRepository;
    private final HmacSignatureFilterConfig config;

    public HmacSignatureFilter(UserSecretKeyRepository userSecretKeyRepository,
            HmacSignatureFilterConfig config) {
        this.userSecretKeyRepository = userSecretKeyRepository;
        this.config = config;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request)
            throws ServletException {
        //do not execute filter if the path is not configured 
        var path = request.getRequestURI();
        return config.getPathPrefix()
                .stream()
                .noneMatch(path::startsWith);
    }

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

        var clientId = request.getHeader("X-ClientId");
        var clientSignature = request.getHeader("X-Signature");
        long clientTimestamp = Objects.nonNull(request.getHeader("X-TimestampMillis"))
                               ? Long.parseLong(request.getHeader("X-TimestampMillis"))
                               : 0;

        //retrieve the user's secret key using the clientId
        var userSecretKey = userSecretKeyRepository.findFirstByClientId(clientId);
        if (Objects.isNull(userSecretKey)) {
            var errMsg = "Invalid Client Identifier";
            response.sendError(HttpStatus.UNAUTHORIZED.value(), errMsg);
            return;
        }

        //determine if the request has expired or not
        long timeDiff = System.currentTimeMillis() - clientTimestamp;
        if (timeDiff > config.getAllowedTimeDiffInMillis()) {
            var errMsg = "Invalid Client Timestamp";
            response.sendError(HttpStatus.UNAUTHORIZED.value(), errMsg);
            return;
        }

        //this class will allow us to read the request body multiple times
        var cachingRequestWrapper = new CachingHttpRequestWrapper(request);

        var payload = cachingRequestWrapper.getRequestBodyString()
                + clientTimestamp;

        //generate the server's HMAC signature
        var hmacSignatureEither = Try.of(() -> HmacHelper.generateHMACSignature(
                payload,
                userSecretKey.getHmacSecretKey())
        ).toEither();

        if (hmacSignatureEither.isLeft()) {
            var throwable = hmacSignatureEither.getLeft();
            logger.error("Error generating Signature", throwable);
            var clientErrMsg = "Invalid Request Signature";
            response.sendError(HttpStatus.UNAUTHORIZED.value(), clientErrMsg);
            return;
        }

        var serverSignature = hmacSignatureEither.get();
        
        //compare server and client signature
        if (!serverSignature.equals(clientSignature)) {
            var errMsg = "Invalid Request Signature";
            response.sendError(HttpStatus.UNAUTHORIZED.value(), errMsg);
            return;
        }


        //signature checks out, proceed to process request
        filterChain.doFilter(cachingRequestWrapper, response);

    }
}

There’s a lot to unpack in the code snippet above. First, we override the shouldNotFilter() to ensure HMAC signature is only required for the paths we configured.

Next, we retrieved the UserSecretKey using the client id from the request header and validate the timestamp, to be sure the request has not expired.

Then we create an instance of CachingHttpRequestWrapper.java from the HttpServletRequest. The CachingHttpRequestWrapper.java will allow us to read the request body multiple times.

We constructed our own payload and signed it. If the signature doesn’t match, we return an appropriate error, otherwise, we allow the process to continue to the controller.

Since we’ve annotated the HmacSignatureFilter.java class with @Component, Spring Boot will automatically configure it and make it available for ALL requests.

Therefore, there’s no need to do any further configuration like using FilterRegistrationBean.

5. Client Test

It’s time to test the application as a whole. We will create integration tests for different scenarios.

Starting with the positive case, where we provide all the required headers and the correct signature.

Listing 5.1 TransferControllerIntegrationTest.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
@Test
void givenValidRequestSignatureAndBasicAuth_whenTransferFund_thenReturnOK()
        throws Exception {

    String basicAuthUsername = "[email protected]";
    String basicAuthPassword = "karibuChangeMe";
    String hmacSecret = "karibuChangeMe";
    String clientId = "[email protected]";

    var request = new TransferRequest("0123456789",
            "[email protected]",
            "7U791239001", 2000.0,
            "NGN", UUID.randomUUID().toString());

    var timestamp = String.valueOf(System.currentTimeMillis());

    String requestString = objectMapper.writeValueAsString(request) + timestamp;
    String clientSignature = generateHMACSignature(requestString, hmacSecret);

    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_JSON);
    headers.setBasicAuth(basicAuthUsername, basicAuthPassword);
    headers.put("X-ClientId", List.of(clientId));
    headers.put("X-TimestampMillis", List.of(timestamp));
    headers.put("X-Signature", List.of(clientSignature));


    var expectMessage = "Fund transferred successfully";
    mockMvc.perform(MockMvcRequestBuilders.post(Routes.TRANSFER)
                    .content(objectMapper.writeValueAsString(request))
                    .headers(headers)
            ).andExpect(status().isOk())
            .andExpect(jsonPath("$.status", equalTo(true)))
            .andExpect(jsonPath("$.statusCode", equalTo(200)))
            .andExpect(jsonPath("$.statusMessage", equalTo(expectMessage)))
            .andExpect(jsonPath("$.reference", equalTo(request.reference())));


}

Let’s add another test to assert the request will fail, if given an invalid signature.

Listing 5.2 TransferControllerIntegrationTest.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
@Test
void givenInvalidRequestSignature_whenTransferFund_thenReturn401Unauthorized()
        throws Exception {

    String basicAuthUsername = "[email protected]";
    String basicAuthPassword = "karibuChangeMe";
    String clientId = "[email protected]";

    //construct a request body
    var request = new TransferRequest("0123456789",
            "[email protected]",
            "7U791239001", 2000.0,
            "NGN", UUID.randomUUID().toString());

    var timestamp = String.valueOf(System.currentTimeMillis());
    String requestString = objectMapper.writeValueAsString(request) + timestamp;

    //signature with the wrong secret key
    String clientSignature = generateHMACSignature(requestString, "wrongKey");

    //add the required headers
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_JSON);
    headers.setBasicAuth(basicAuthUsername, basicAuthPassword);
    headers.put("X-ClientId", List.of(clientId));
    headers.put("X-TimestampMillis", List.of(timestamp));
    headers.put("X-Signature", List.of(clientSignature));


    mockMvc.perform(MockMvcRequestBuilders.post(Routes.TRANSFER)
                    .content(objectMapper.writeValueAsString(request))
                    .headers(headers)
            )
            .andExpect(status().is(401));

}

Lastly, let’s test that if we invoke an endpoint that is not configured for HMAC signature, it will return successfully, despite not having the signature headers.

Listing 5.3 TransferControllerIntegrationTest.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@Test
void givenNoSignature_whenIndex_thenReturnSuccessful()
        throws Exception {

    String basicAuthUsername = "[email protected]";
    String basicAuthPassword = "karibuChangeMe";

    HttpHeaders headers = new HttpHeaders();
    headers.setBasicAuth(basicAuthUsername, basicAuthPassword);
    
    mockMvc.perform(MockMvcRequestBuilders.get(Routes.INDEX)
                    .headers(headers)
            ).andExpect(status().isOk())
            .andExpect(content().string("Hello World"));

}

I will leave out other cases as class work. Feel free to share your solutions with me.

6. Conclusion

HMAC signature is an effective technique for protecting APIs by verifying the origin of requests. It can be combined with other techniques like IP whitelisting, MTLs, and API Key to provide robust API security, especially for RESTful APIs.

The complete source code is available on GitHub.

If you find this tutorial helpful, don’t forget to check out our other Spring Boot security articles. Share your thoughts or ask questions in the comments below!

Happy Coding

Seun Matt

Results-driven Engineer, dedicated to building elite teams that consistently achieve business objectives and drive profitability. With over 8 years of experience, spannning different facets of the FinTech space; including digital lending, consumer payment, collections and payment gateway using Java/Spring Boot technologies, PHP and Ruby on Rails