Turning one secret into two padded keys
HMAC first pads (or, if it's longer than one block, hashes down) your secret to SHA-256's 64-byte block size, producing K′. That padded key is then XORed with two fixed constants — ipad (0x36 repeated) and opad (0x5c repeated) — producing two different derived keys from the one secret you typed.
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, // tells WebCrypto which hash HMAC should wrap
false, // extractable — false, since it's only ever used to sign
["sign"] // this demo never verifies via a separate "verify" usage
);The K′ padding and the ipad/opad XOR described above aren't things this code does — they happen inside crypto.subtle the moment you import the key with { name: "HMAC", hash: "SHA-256" }.
Two nested hashes
The inner hash covers the padded-and-XORed key plus your actual message. The outer hash covers the padded-and-XORed key (with the other constant) plus that inner result. The hex string labeled "Tag" is the output of the outer hash — a single 32-byte value.
const tag = await crypto.subtle.sign(
"HMAC",
key, // the imported key from the step above
new TextEncoder().encode(message)
);
// tag is a 32-byte ArrayBuffer — hex-encoded above as "Tag"sign() runs both nested hashes in the formula above and returns only the final 32-byte result — there's no intermediate access to the inner hash from JavaScript.
Why not just H(secret ‖ message)?
The simpler-looking H(secret ‖ message) is vulnerable to length-extension attacks, covered in detail in the Hash functions module — an attacker who knows H(secret ‖ message) can compute H(secret ‖ message ‖ extra) without ever learning the secret. HMAC's nested double-hash construction closes that gap; it's not a stylistic choice.
What the Verify button actually checks
It recomputes HMAC(secret, message) from scratch, using whatever is currently in the message and secret fields, and compares the result to the tag you provided. Production systems use a constant-time comparison for this exact step — comparing byte by byte and stopping at the first mismatch leaks timing information about how many leading bytes matched, the same class of issue covered in the side-channel module. This demo uses a simple equality check for clarity.
const recomputed = await hmacSha256Hex(verifySecret, verifyMessage);
// hmacSha256Hex() runs the same importKey + sign calls shown above
if (recomputed === verifyTag.trim().toLowerCase()) {
// valid — but this is a plain string comparison, not constant-time
} else {
// invalid
}WebCrypto does have a purpose-built, constant-time check — crypto.subtle.verify("HMAC", key, tagBytes, messageBytes) — which recomputes the tag internally and never leaks timing information. This tool deliberately bypasses it and compares strings by hand instead, precisely to make the constant-time point above concrete rather than hidden inside a built-in.