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); }} 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
Mac.getInstance("HmacSHA256")initialised withnew SecretKeySpec(secret, "HmacSHA256")returns the 32-byte MAC fromdoFinal(body);HexFormat.of().formatHexgives the lower-case hex aftersha256=.MessageDigest.isEqualcompares two byte arrays in constant time;Arrays.equalsreturns at the first differing byte and must not be used for MACs.- A
Macobject keeps state between calls and is not thread-safe, sohmacSha256creates one per call instead of sharing a field across request threads. - The handler reads the body with
getRequestBody().readAllBytes()before anything parses it, and looks the header up case-insensitively withgetRequestHeaders().getFirst("X-Hub-Signature-256"). - The run checks GitHub's example —
Hello, World!with the secretIt's a Secret to Everybodygivessha256=757107ea…3e17— and posts a real request to the server; Java 22+ starts the three source files with a singlejava Check.java.
Gotchas
- An empty secret — typically an environment variable that was never set — makes
new SecretKeySpec(new byte[0], "HmacSHA256")throwjava.lang.IllegalArgumentException: Empty key. Check the configuration at start-up rather than on the first delivery. HexFormat.of().parseHexthrowsIllegalArgumentExceptionfor an odd number of digits or a non-hex character; catch it and reject the request, asverifydoes, 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 asbyte[]before JSON binding and verify those bytes. - A missing header (
getFirstreturnsnull) must fail the request, not skip the check.