Skip to content
ecdsa.com

HMAC-SHA256 sign/verify · WebCrypto

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

To sign and verify a message with HMAC-SHA256 in WebCrypto, import the secret with crypto.subtle.importKey("raw", …, { name: "HMAC", hash: "SHA-256" }) and call crypto.subtle.sign or crypto.subtle.verify — verify does the comparison itself. This recipe checks GitHub's documented webhook example and verifies deliveries in a fetch handler built only on Web APIs (Request, Response, crypto.subtle).

Tested against the WebCrypto API in WebCrypto Node.js 24.22.0 (globalThis.crypto.subtle, Request and Response globals) — the same crypto.subtle interface browsers implement.

webhook.mjs — sign and verify a sha256=<hex> header with crypto.subtle
const encoder = new TextEncoder();
// A non-extractable HMAC key usable in both directions
function 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 itself
export 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);
}
handler.mjs — a fetch(request, env) handler that verifies the raw body before parsing JSON
import { verify } from "./webhook.mjs";
// A fetch handler built on Web APIs only: Request in, Response out
export 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

  1. importKey("raw", …) turns the secret's UTF-8 bytes into a non-extractable CryptoKey with the usages sign and verify.
  2. crypto.subtle.sign("HMAC", key, rawBody) returns an ArrayBuffer of 32 bytes; hex-encoded after sha256=, GitHub's example (Hello, World!, secret It's a Secret to Everybody) gives sha256=757107ea…3e17.
  3. 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.
  4. 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 wherever fetch handlers do; the run drives it with Node.js 24's Request and Response.

Gotchas

  • Key usages are enforced: verify with a key imported only for ["sign"] rejects with InvalidAccessError. Import the key with the usages you will call.
  • subtle.sign and subtle.verify take a BufferSource, not a string: passing "Hello, World!" rejects with a TypeError. Encode text with TextEncoder, and pass request bodies as the bytes you received.
  • subtle.verify returns false — it does not throw — for a MAC of the wrong length (31 bytes in the run), so if (!(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 with 401. Read request.arrayBuffer(), not request.json(), before verifying.

Related recipes