Webhook Request Verification: Securing Your System with HMAC SHA256
Table of Contents
- Ensuring Data Integrity and Verification with HMACSHA256
- Adding Layers of Trust via Timestamp and Signature Headers
- Rejecting Suspicious Webhook Requests
- Closing Words on Request Verification
- Summary
In my previous post, we looked at an efficient handling of webhooks to boost real-time data synchronization between different systems. However, with this transfer of data comes a great concern for security and data integrity. This is where webhook request verification comes into play: it ensures that only authorized requests are accepted and processed.
A common and effective method of validating webhook requests incorporates the use of HMAC (Hash-based Message Authentication Code) SHA256, a timestamp and signature headers. These elements combined ensure that only verified requests are processed.
Let’s take our earlier example of webhooks with Spring Webflux, Kotlin and coroutines and add another layer of security by implementing verification of incoming requests. As usual, you can find the full source code on GitHub.
Ensuring Data Integrity and Verification with HMACSHA256
HMAC (Hash-based Message Authentication Code) is a specific type of message authentication code (MAC) involving a cryptographic hash function and a secret key. HMAC SHA256 is a variant that uses the SHA-256 (Secure Hash Algorithm 256 bit) cryptographic function.
Java’s javax.crypto package has all we need to implement the verification algorithm in Kotlin.
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
@Bean
fun hmacAlgorithm(secret: String): Mac {
val hmacSha256 = "HmacSHA256"
val hmac = Mac.getInstance(hmacSha256)
val secretKeySpec = SecretKeySpec(secret.toByteArray(), hmacSha256)
hmac.init(secretKeySpec)
return hmac
}
After we have initialized the hash-based message authentication code with the secret key, we can use this to sign our request data salted by a timestamp.
Adding Layers of Trust via Timestamp and Signature Headers
To further enhance the security protocol, we should include a timestamp and signature headers.
A timestamp header, as the name indicates, carries the time of when the request was made. This provides an initial layer of security, since the server can reject older requests. This helps mitigate replay attacks.
The signature header works as a digital seal that allows the recipient to authenticate the message source. The process involves generating the signature on the request sender’s side by creating a hashed combination of the payload and the timestamp, using a shared secret key. The hashed string, encrypted with HMAC SHA256, forms the signature header.
Thanks to Spring we can easily extract the headers from the request and treat them as standalone arguments.
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
@PostMapping
suspend fun handleWebhook(
request: ServerHttpRequest,
@RequestHeader("x-custom-request-timestamp") timestampHeader: String,
@RequestHeader("x-custom-signature") sigHeader: String,
): ResponseEntity<Unit> { .. }
Please note that the header names are arbitrary. Always check with your integration partner what the headers should be called and if you can provide your own custom names.
Rejecting Suspicious Webhook Requests
Now that we understand how to verify requests, we can effectively discard any request that fails the verification. The majority of third-party integrations employing webhooks support 2xx response codes, signifying a successful verification, as well as 4xx response codes, indicating a failed verification. It’s essential to prevent waste of resources by processing invalid inputs. More importantly, you want to avoid the risk of corrupting your database or application logs with potentially malicious data.
import org.springframework.http.server.reactive.ServerHttpRequest
@PostMapping
suspend fun handleWebhook(
request: ServerHttpRequest,
@RequestHeader(TIMESTAMP_HEADER) timestampHeader: String,
@RequestHeader(SIG_HEADER) sigHeader: String,
): ResponseEntity<Unit> {
val requestBody = request.body.single()
.awaitFirst()
.toString(Charsets.UTF_8)
logger.debug("Received webhook request: {}", requestBody)
if (isTrusted(timestampHeader, sigHeader, requestBody)) {
// Process the request
ResponseEntity.ok().build()
} else {
// Ignore the request
ResponseEntity.status(403).build()
}
}
In this brief example (you can find the complete implementation here), we extract the request’s body as a string. Next, we pass it, along with the timestamp and signature headers, to the verification function. Should the request prove untrustworthy, it is immediately discarded, and the endpoint returns a 403 response, indicating rejection. If the request passes the verification, asynchronous processing in the background kicks off, and the endpoint promptly delivers a successful response.
Please remember that maintaining fast response times is essential. Avoid blocking the request with time-consuming operations. This practice helps prevent unnecessary retries and reduces the overhead for both your system and your integration partner’s one.
Closing Words on Request Verification
The signing function makes use of the HMAC SHA256 algorithm and returns a base64-encoded signature consisting of the provided timestamp and data. Please bear in mind that the delimiter, a dot (.) in this instance, may vary. Always consult with your integration partner to understand the exact specifications for the signature format.
import javax.crypto.Mac
@Autowired
private late init var hmacAlgorithm: Mac
fun signData(data: String, timestamp: String): String {
val dataToSign = "$timestamp.$data"
val signedData = hmacAlgorithm.doFinal(dataToSign.toByteArray())
return Base64.getEncoder().encodeToString(signedData)
}
The verification function is quite straightforward. By passing the values obtained from the timestamp and signature headers, we can effortlessly determine the validity of the request payload.
fun isTrusted(timestamp: String, expectedSignature: String, data: String): Boolean {
val dataSignature = signData(data, timestamp)
return dataSignature == expectedSignature
}
Summary
HMAC SHA256, along with timestamp and signature headers, establishes a secure mechanism for maintaining data integrity and authenticity. Make sure to verify each webhook request before you consider it for further processing.
Explore the full example is on GitHub and let me know your thoughts. Thanks for reading.