import java.security.MessageDigestimport javax.crypto.Macimport 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
Mac.getInstance("HmacSHA256")withSecretKeySpec(secret, "HmacSHA256")computes the MAC;ByteArray.toHexString()from the Kotlin standard library prints it in lower case aftersha256=.verifyrejects a header without thesha256=prefix, decodes the rest withhexToByteArray()insiderunCatching, and returnsfalsefor anything that does not decode.MessageDigest.isEqualcompares in constant time;ByteArray.contentEqualsstops at the first difference and is the wrong tool for MACs.- A
Mackeeps state and is not thread-safe, sohmacSha256creates 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. - The run checks GitHub's example:
Hello, World!with the secretIt's a Secret to Everybodygivessha256=757107ea…3e17, and a tampered body, a wrong secret, a truncated or a missing header all fail.
Gotchas
"757".hexToByteArray()throwsNumberFormatExceptionfor an odd number of digits. WithoutrunCatching(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 verifiessha256=757107EA…— so the comparison happens on bytes, not on hex strings whose case may differ between senders.MessageDigest.isEqualreturnsfalsefor 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.