const encoder = new TextEncoder(); // A non-extractable HMAC key usable in both directionsfunction importSecret(secret) { return crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);} // Sender: HMAC-SHA256 over the exact body bytes, sent as "sha256=<hex>"export async function sign(secret, rawBody) { const mac = new Uint8Array(await crypto.subtle.sign("HMAC", await importSecret(secret), rawBody)); return "sha256=" + Array.from(mac, (byte) => byte.toString(16).padStart(2, "0")).join("");} // Receiver: subtle.verify recomputes the MAC and does the comparison itselfexport async function verify(secret, rawBody, header) { const match = /^sha256=([0-9a-f]{64})$/i.exec(header ?? ""); if (!match) return false; // a missing or malformed header fails, it never skips the check const received = Uint8Array.from(match[1].match(/../g), (pair) => parseInt(pair, 16)); return crypto.subtle.verify("HMAC", await importSecret(secret), received, rawBody);} import { verify } from "./webhook.mjs"; // A fetch handler built on Web APIs only: Request in, Response outexport default { async fetch(request, env) { const rawBody = await request.arrayBuffer(); // the bytes as received, before JSON parsing if (!(await verify(env.WEBHOOK_SECRET, rawBody, request.headers.get("X-Hub-Signature-256")))) { return new Response("bad signature\n", { status: 401 }); } const event = JSON.parse(new TextDecoder().decode(rawBody)); return new Response(`accepted ${event.action} #${event.number}\n`); },}; How it works
importKey("raw", …)turns the secret's UTF-8 bytes into a non-extractableCryptoKeywith the usagessignandverify.crypto.subtle.sign("HMAC", key, rawBody)returns anArrayBufferof 32 bytes; hex-encoded aftersha256=, GitHub's example (Hello, World!, secretIt's a Secret to Everybody) givessha256=757107ea…3e17.- To verify, decode the received hex and pass it to
crypto.subtle.verify("HMAC", key, received, rawBody): it recomputes the MAC and compares, so there is no hand-written comparison to get wrong. - The handler reads
request.arrayBuffer()— the body bytes as received — verifies them, then decodes and parses JSON. It uses no runtime-specific API, so the same code runs whereverfetchhandlers do; the run drives it with Node.js 24'sRequestandResponse.
Gotchas
- Key usages are enforced:
verifywith a key imported only for["sign"]rejects withInvalidAccessError. Import the key with the usages you will call. subtle.signandsubtle.verifytake aBufferSource, not a string: passing"Hello, World!"rejects with aTypeError. Encode text withTextEncoder, and pass request bodies as the bytes you received.subtle.verifyreturnsfalse— it does not throw — for a MAC of the wrong length (31 bytes in the run), soif (!(await verify(...)))is all the handling it needs.- The MAC covers bytes, not JSON: the event re-serialized with
JSON.stringify(JSON.parse(body))is rejected with401. Readrequest.arrayBuffer(), notrequest.json(), before verifying.