HMAC-SHA256 Explained with Node.js and Python Examples

2026-06-30

HMAC-SHA256 creates a message authentication code from a secret key and a message. SHA-256 alone answers, “Did these bytes change?” An HMAC additionally answers, “Did someone holding this shared key authenticate these bytes?” It does not encrypt the message, and it does not provide non-repudiation because every verifier with the key could also create a valid tag.

Use the HMAC generator to compare inputs while learning, and the hash tool to see how an unkeyed digest differs.

Node.js example

Agree on the exact byte representation before signing. The following example signs UTF-8 text and emits lowercase hexadecimal:

import { createHmac, timingSafeEqual } from 'node:crypto';

function sign(message, secret) {
  return createHmac('sha256', secret)
    .update(message, 'utf8')
    .digest('hex');
}

const secret = process.env.WEBHOOK_SECRET;
if (!secret) throw new Error('WEBHOOK_SECRET is required');

const body = '{"event":"invoice.paid","id":"evt_example"}';
const signature = sign(body, secret);
console.log(signature);

Never hard-code a real key. Load it from a secret manager or protected environment variable and rotate it according to your threat model.

Verification should compare fixed-length byte sequences in constant time:

function verify(message, suppliedHex, secret) {
  if (!/^[0-9a-f]{64}$/i.test(suppliedHex)) return false;

  const expected = createHmac('sha256', secret).update(message).digest();
  const supplied = Buffer.from(suppliedHex, 'hex');
  return supplied.length === expected.length &&
    timingSafeEqual(supplied, expected);
}

Length validation matters because timingSafeEqual throws when buffers differ in length. A constant-time primitive reduces timing leakage, although the surrounding application must also avoid visibly different failure paths.

Python example

Python's standard library provides compatible operations:

import hashlib
import hmac
import os

secret = os.environ["WEBHOOK_SECRET"].encode("utf-8")
body = b'{"event":"invoice.paid","id":"evt_example"}'

signature = hmac.new(secret, body, hashlib.sha256).hexdigest()
print(signature)

def verify(message: bytes, supplied_hex: str, key: bytes) -> bool:
    expected = hmac.new(key, message, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, supplied_hex.lower())

Node and Python produce the same result only when key bytes, message bytes, algorithm, and output encoding all match. Hex and Base64 are representations of the tag; they are not interchangeable.

When interoperability fails, capture a non-sensitive test vector: a fixed sample key, the exact message represented as UTF-8 bytes or hexadecimal, and the expected tag. Check whether one side includes a trailing newline, decodes a Base64 key while the other uses its literal characters, or signs uppercase hexadecimal text instead of the underlying bytes. Test vectors should use a dedicated public example key, never a shortened production key. Once both implementations pass the same vector, test the complete request construction separately.

Sign a stable request representation

For webhooks, sign the raw request body, not JSON parsed and serialized again. Whitespace, property order, escaping, and line endings can change the bytes:

timestamp + "." + raw_body

Including a timestamp and rejecting old requests limits replay:

signed = f"{timestamp}.".encode() + raw_body

Define the separator, timestamp format, character encoding, and signature encoding in the protocol. If multiple values can be concatenated ambiguously, use length prefixes or an established canonicalization scheme. Store recent event IDs when stronger replay protection is required.

Key selection and common mistakes

Generate high-entropy keys with a cryptographically secure random generator. Do not use a human password directly; if a password is unavoidable, derive a key with a suitable password-based KDF and stored parameters. Keep keys out of URLs, logs, fixtures, client-side JavaScript, and source control. Separate keys by environment and purpose so a development webhook key cannot authenticate production traffic.

Common failures include hashing secret + message manually, comparing signatures with ordinary string equality, signing parsed JSON, and silently accepting SHA-1 because a client requested it. Another subtle bug is authenticating the body but not security-critical context such as a timestamp, method, or path when the protocol requires it.

HMAC-SHA256 remains a strong choice when both parties can securely share a secret. If verifiers should not be able to sign, use an asymmetric signature design instead. In either case, precise byte-level protocol documentation is as important as choosing the primitive.