Skip to content
ecdsa.com

HMAC-SHA256 sign/verify · Java

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

To sign and verify a message with HMAC-SHA256 in Java, use javax.crypto.Mac with the HmacSHA256 algorithm from the JDK and compare the result with MessageDigest.isEqual, which runs in constant time. The typical case is a webhook signature header; this recipe checks GitHub's documented example and verifies deliveries in a handler on the JDK's built-in HTTP server.

Tested with OpenJDK 25.0.4.1 (Eclipse Temurin 25.0.4.1+1, javax.crypto.Mac and com.sun.net.httpserver).

Webhook.java — sign and verify a sha256=<hex> header
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class Webhook {
private Webhook() {}
/** Sender: HMAC-SHA256 over the exact body bytes, sent as "sha256=<hex>". */
public static String sign(byte[] secret, byte[] rawBody) throws GeneralSecurityException {
return "sha256=" + HexFormat.of().formatHex(hmacSha256(secret, rawBody));
}
/** Receiver: recompute over the raw body and compare in constant time. */
public static boolean verify(byte[] secret, byte[] rawBody, String header) throws GeneralSecurityException {
if (header == null || !header.startsWith("sha256=")) return false; // a missing header fails
byte[] received;
try {
received = HexFormat.of().parseHex(header, 7, header.length());
} catch (IllegalArgumentException e) {
return false; // not hex, or an odd number of digits
}
return MessageDigest.isEqual(received, hmacSha256(secret, rawBody)); // not Arrays.equals
}
private static byte[] hmacSha256(byte[] secret, byte[] data) throws GeneralSecurityException {
Mac mac = Mac.getInstance("HmacSHA256"); // a Mac is not thread-safe: one per call
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
return mac.doFinal(data);
}
}
WebhookServer.java — a com.sun.net.httpserver handler that verifies the raw body
import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
public final class WebhookServer {
private WebhookServer() {}
/** The JDK's built-in HTTP server; the handler reads the body as bytes before anything parses it. */
public static HttpServer start(byte[] secret, int port) throws java.io.IOException {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
server.createContext("/webhook", exchange -> {
byte[] rawBody = exchange.getRequestBody().readAllBytes();
String header = exchange.getRequestHeaders().getFirst("X-Hub-Signature-256"); // case-insensitive
boolean valid;
try {
valid = Webhook.verify(secret, rawBody, header);
} catch (GeneralSecurityException e) {
valid = false;
}
byte[] reply = (valid ? "accepted\n" : "bad signature\n").getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(valid ? 200 : 401, reply.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(reply);
}
});
server.start();
return server;
}
}

How it works

  1. Mac.getInstance("HmacSHA256") initialised with new SecretKeySpec(secret, "HmacSHA256") returns the 32-byte MAC from doFinal(body); HexFormat.of().formatHex gives the lower-case hex after sha256=.
  2. MessageDigest.isEqual compares two byte arrays in constant time; Arrays.equals returns at the first differing byte and must not be used for MACs.
  3. A Mac object keeps state between calls and is not thread-safe, so hmacSha256 creates one per call instead of sharing a field across request threads.
  4. The handler reads the body with getRequestBody().readAllBytes() before anything parses it, and looks the header up case-insensitively with getRequestHeaders().getFirst("X-Hub-Signature-256").
  5. The run checks GitHub's example — Hello, World! with the secret It's a Secret to Everybody gives sha256=757107ea…3e17 — and posts a real request to the server; Java 22+ starts the three source files with a single java Check.java.

Gotchas

  • An empty secret — typically an environment variable that was never set — makes new SecretKeySpec(new byte[0], "HmacSHA256") throw java.lang.IllegalArgumentException: Empty key. Check the configuration at start-up rather than on the first delivery.
  • HexFormat.of().parseHex throws IllegalArgumentException for an odd number of digits or a non-hex character; catch it and reject the request, as verify does, instead of letting the handler fail with a 500.
  • The MAC covers bytes, not JSON: the same event minified is rejected with 401. In any framework, take the body as byte[] before JSON binding and verify those bytes.
  • A missing header (getFirst returns null) must fail the request, not skip the check.

Related recipes