Skip to content
ecdsa.com

HMAC-SHA256 sign/verify · Kotlin

How to sign and verify a message with HMAC-SHA256 in Kotlin

To sign and verify a message with HMAC-SHA256 in Kotlin, use the JDK's javax.crypto.Mac with HmacSHA256 and compare with MessageDigest.isEqual for a constant-time check; Kotlin's toHexString and hexToByteArray handle the hex encoding. This recipe signs and verifies a webhook header such as X-Hub-Signature-256 and checks GitHub's documented example.

Tested with Kotlin 2.4.21 (Kotlin/JVM, OpenJDK 25.0.4.1 (Temurin), Gradle 9.8.1).

Webhook.kt — sign and verify a sha256=<hex> header
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/** Sender: HMAC-SHA256 over the exact body bytes, sent as "sha256=<hex>". */
fun sign(secret: ByteArray, rawBody: ByteArray): String = "sha256=" + hmacSha256(secret, rawBody).toHexString()
/** Receiver: recompute over the raw body and compare in constant time. */
fun verify(secret: ByteArray, rawBody: ByteArray, header: String?): Boolean {
if (header == null || !header.startsWith("sha256=")) return false // a missing header fails
val received = runCatching { header.substring(7).hexToByteArray() }.getOrElse { return false }
return MessageDigest.isEqual(received, hmacSha256(secret, rawBody)) // not contentEquals
}
private fun hmacSha256(secret: ByteArray, data: ByteArray): ByteArray =
Mac.getInstance("HmacSHA256").run { // a Mac is not thread-safe: one per call
init(SecretKeySpec(secret, "HmacSHA256"))
doFinal(data)
}

How it works

  1. Mac.getInstance("HmacSHA256") with SecretKeySpec(secret, "HmacSHA256") computes the MAC; ByteArray.toHexString() from the Kotlin standard library prints it in lower case after sha256=.
  2. verify rejects a header without the sha256= prefix, decodes the rest with hexToByteArray() inside runCatching, and returns false for anything that does not decode.
  3. MessageDigest.isEqual compares in constant time; ByteArray.contentEquals stops at the first difference and is the wrong tool for MACs.
  4. A Mac keeps state and is not thread-safe, so hmacSha256 creates one per call — safe to call from concurrent request handlers. Compute the MAC over the request body bytes exactly as received, before any conversion to a string or a JSON object.
  5. The run checks GitHub's example: Hello, World! with the secret It's a Secret to Everybody gives sha256=757107ea…3e17, and a tampered body, a wrong secret, a truncated or a missing header all fail.

Gotchas

  • "757".hexToByteArray() throws NumberFormatException for an odd number of digits. Without runCatching (or a try/catch), a garbled header turns into a server error instead of a rejected request.
  • hexToByteArray() accepts upper-case digits too — the run verifies sha256=757107EA… — so the comparison happens on bytes, not on hex strings whose case may differ between senders.
  • MessageDigest.isEqual returns false for arrays of different lengths, so a truncated header is rejected (truncated header: false) without a separate length check — never trim the computed MAC to the received length.

Related recipes