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