Webhook Security
Your webhook endpoint will be accessible to the public internet, so it's important that you verify incoming webhooks to ensure they have actually come from LHV. Every incoming webhook will have an X-LHV-HMAC
header, containing a hex-encoded SHA256 HMAC formed from the webhook body and your shared secret.
Managing Your Shared Secret
Every webhook configuration has a secret, shared between you and LHV, which is used for verifying webhook payloads. You can use a same one for different configurations, or have each one be different.
You can either set the secret yourself, or leave it absent and we will generate one automatically. To increase security, we will only show you the secret once - in the response from a create/edit request.
When you edit an existing webhook, you can overwrite the secret with a new one you choose, or leave it absent to generate a new secret (used with immediate effect). If you are editing other fields but want to keep the same secret, then you must provide it in the edit request body.
The secrets themselves are represented as strings, but HMACs work on raw bytes, so you will need to convert it to a byte array of UTF-8 characters (example code is shown later). You can use any valid character in your secret, but it's often wise to avoid special characters anyway.
Secrets should have high entropy to ensure security. For our automaticly-generated secrets, we use a 64-character alphabet ([a-zA-Z0-9_\-]
) and generate a string of length 64. This works out to 384 bits of entropy (LENGTH * log2(ALPHABET_SIZE)
).
Verifying HMACs
To verify a webhook, you should generate the expected HMAC in the same way, and then confirm that it matches the value from the header. If the result is not an exact match (or header is missing), you should discard the webhook as illegitimate, and log this as an error.
For security, you should use a constant-time equals function (as shown in the code examples). If you compare hex strings rather than bytes, then do so case insensitively (e.g. "af12" == "AF12").
Here is rough pseudocode for how you should use the result of HMAC validation. Actual implementations of verifyHmac
are given in the next section
@POSTHandler("/my-webhook-endpoint")
fun webhookHandler(incomingWebhook: HttpMessage) {
val rawPayload: ByteArray = incomingWebhook.requestBodyAsBytes
val hmacString = incomingWebhook.headers["X-LHV-HMAC"]
if (hmacString != null && verifyHmac(rawPayload, MY_SECRET, hmacString)) {
process(incomingWebhook)
} else {
log.error("Webhook with invalid HMAC received!")
fail()
}
}
Note that rawPayload
must be the exact bytes of the HTTP request body. Whitespace trimming or reformatting will result in a failed HMAC comparison. If you can only extract the body as a string then you can still convert it to a UTF-8 byte array - the example code does this to turn secret
into secretBytes
, so you can copy that for the request body.
Once you have verified the HMAC of the message, it's also worth making sure that the clientCode
and clientCountry
fields are correct, and that the eventTimestamp
is within expected bounds.
Example verifyHmac Code
Kotlin
import java.security.MessageDigest;
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
// https://mvnrepository.com/artifact/commons-codec/commons-codec
import org.apache.commons.codec.binary.Hex
const val HMAC_ALGORITHM = "HmacSHA256"
private fun generateHmac(payload: ByteArray, secret: String): ByteArray {
val secretBytes = secret.encodeToByteArray()
val keySpec = SecretKeySpec(secretBytes, HMAC_ALGORITHM)
val mac = Mac.getInstance(HMAC_ALGORITHM)
mac.init(keySpec)
return mac.doFinal(payload)
}
fun verifyHmac(payload: ByteArray, secret: String, receivedHmacHeader: String): Boolean {
val receivedHmac = Hex.decodeHex(receivedHmacHeader)
val expectedHmac = generateHmac(payload, secret)
return MessageDigest.isEqual(receivedHmac, expectedHmac)
}
Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.MessageDigest;
import java.util.Arrays;
// https://mvnrepository.com/artifact/commons-codec/commons-codec
import org.apache.commons.codec.DecoderException;
import org.apache.commons.codec.binary.Hex;
// Class definition omitted
private static final String HMAC_ALGORITHM = "HmacSHA256";
private static byte[] generateHmac(byte[] payload, String secret)
throws NoSuchAlgorithmException, InvalidKeyException {
byte[] secretBytes = secret.getBytes(StandardCharsets.UTF_8)
SecretKeySpec keySpec = new SecretKeySpec(secretBytes, HMAC_ALGORITHM);
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(keySpec);
return mac.doFinal(payload);
}
public static boolean verifyHmac(byte[] payload, String secret, String receivedHmacHeader)
throws NoSuchAlgorithmException, InvalidKeyException, DecoderException {
byte[] receivedHmac = Hex.decodeHex(receivedHmacHeader);
byte[] expectedHmac = generateHmac(payload, secret);
return MessageDigest.isEqual(receivedHmac, expectedHmac);
}
Example Payload and HMAC
You can use these values to test that you're generating HMACs correctly:
Secret:
example_secret_for_docs
Payload:
{"eventId":"bd960667-37cf-4698-b63a-919aa282ef3c","eventTimestamp":"2024-07-10T10:39:33.295567","subscriptionReference":"2345","messageResponseId":"0566defb-2c1b-4649-9c1d-cda27d2f5a10","messageRequestId":"dd14b64d-0553-4bea-acb5-71f45697e126","messageType":"VIBAN_OPEN","messageCreatedTime":"2024-07-10T10:39:33.263134","clientCode":"123","clientCountry":"GB","bankCode":"LHVUK"}
X-LHV-HMAC
:79ece3b561a9a95a56edf5d8c63224b1fa43f0198442537abe22a7e3ba99e774
Remember that you must copy these exactly. Even a stray space at the end will invalidate it.
Last updated
Was this helpful?