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)).

If you're generating your own secret, remember to use a cryptographically secure PRNG. For Kotlin/Java/JVM, try SecureRandom.getInstanceStrong()

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.

Make sure to always use the SHA256algorithm when processing HMACs.

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.

Having trouble with the above example? Here are the same secret and payload encoded in base64. Try decoding and using them in your test instead.

If that solves the problem then it was probably just down to browser encoding or copy and paste issues, not your implementation.

  • Secret: ZXhhbXBsZV9zZWNyZXRfZm9yX2RvY3M=

  • Payload: eyJldmVudElkIjoiYmQ5NjA2NjctMzdjZi00Njk4LWI2M2EtOTE5YWEyODJlZjNjIiwiZXZlbnRUaW1lc3RhbXAiOiIyMDI0LTA3LTEwVDEwOjM5OjMzLjI5NTU2NyIsInN1YnNjcmlwdGlvblJlZmVyZW5jZSI6IjIzNDUiLCJtZXNzYWdlUmVzcG9uc2VJZCI6IjA1NjZkZWZiLTJjMWItNDY0OS05YzFkLWNkYTI3ZDJmNWExMCIsIm1lc3NhZ2VSZXF1ZXN0SWQiOiJkZDE0YjY0ZC0wNTUzLTRiZWEtYWNiNS03MWY0NTY5N2UxMjYiLCJtZXNzYWdlVHlwZSI6IlZJQkFOX09QRU4iLCJtZXNzYWdlQ3JlYXRlZFRpbWUiOiIyMDI0LTA3LTEwVDEwOjM5OjMzLjI2MzEzNCIsImNsaWVudENvZGUiOiIxMjMiLCJjbGllbnRDb3VudHJ5IjoiR0IiLCJiYW5rQ29kZSI6IkxIVlVLIn0=

Last updated