BytePane

HMAC-SHA256 in Node.js, Python and Go: Verified Snippets

Security5 min read

Quick answer

Node: crypto.createHmac('sha256', key).update(message).digest('hex'). Python: hmac.new(key, message, hashlib.sha256).hexdigest(). Go: hmac.New(sha256.New, key), Write, Sum(nil). With the same key and message bytes all three return the same 32-byte tag, the Node and Python runs below print the identical hex 240b11d6…5a3d. Verify with a constant-time comparison (timingSafeEqual, hmac.compare_digest, hmac.Equal), never with ===.

What HMAC adds on top of SHA-256

RFC 2104 defines HMAC as H(K XOR opad, H(K XOR ipad, text)): the message is hashed once with an inner-padded key, and that result is hashed again with an outer-padded key. Keys longer than the hash block size are hashed first; shorter keys are zero-padded. The RFC says a key shorter than the hash output length (32 bytes for SHA-256) “is strongly discouraged”. The practical consequences:

LanguageComputeConstant-time verifyOutput helpers
Node.jscreateHmac('sha256', key)crypto.timingSafeEqual(a, b), both Buffers must have equal length or it throws.digest('hex' | 'base64' | 'base64url')
Pythonhmac.new(key, msg, hashlib.sha256)hmac.compare_digest(a, b).hexdigest(), .digest() + base64.b64encode
Gohmac.New(sha256.New, key)hmac.Equal(mac1, mac2)hex.EncodeToString(mac.Sum(nil))

Node.js

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

const key = 'my-secret-key';
const message = '{"id":123,"event":"order.paid"}';

const hex = createHmac('sha256', key).update(message).digest('hex');
console.log(hex);
console.log(createHmac('sha256', key).update(message).digest('base64'));

function verify(message, receivedHex, key) {
  const expected = Buffer.from(createHmac('sha256', key).update(message).digest('hex'), 'hex');
  const received = Buffer.from(receivedHex, 'hex');
  return expected.length === received.length && timingSafeEqual(expected, received);
}
console.log('valid:', verify(message, hex, key));
console.log('tampered:', verify(message + ' ', hex, key));

// $ node hmac.mjs
// 240b11d681b1f0ec9e7e107c0e6f44d2f472a500fe71098f7807e9e1ebad5a3d
// JAsR1oGx8OyefhB8Dm9E0vRypQD+cQmPeAfp4eutWj0=
// valid: true
// tampered: false

The length check before timingSafeEqual matters: the function throws when the buffers differ in length, and a thrown error is itself a fast-path signal. update() accepts a string (UTF-8 by default) or a Buffer; for webhook bodies pass the raw request bytes, not a re-stringified object.

Python

import hmac, hashlib, base64

key = b"my-secret-key"
message = b'{"id":123,"event":"order.paid"}'

digest = hmac.new(key, message, hashlib.sha256)
print(digest.hexdigest())
print(base64.b64encode(hmac.new(key, message, hashlib.sha256).digest()).decode())

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

print("valid:", verify(message, digest.hexdigest(), key))
print("tampered:", verify(message + b" ", digest.hexdigest(), key))

# $ python3 hmac_test.py
# 240b11d681b1f0ec9e7e107c0e6f44d2f472a500fe71098f7807e9e1ebad5a3d
# JAsR1oGx8OyefhB8Dm9E0vRypQD+cQmPeAfp4eutWj0=
# valid: True
# tampered: False

Both key and message must be bytes; passing a str raises TypeError. The hex and Base64 outputs match the Node run byte for byte, which is the property you rely on when one service signs and another verifies.

Go

This mirrors the ValidMAC example in the official crypto/hmac package documentation, which notes that receivers “should be careful to use Equal to compare MACs in order to avoid timing side-channels”:

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
)

func Sign(message, key []byte) string {
    mac := hmac.New(sha256.New, key)
    mac.Write(message)
    return hex.EncodeToString(mac.Sum(nil))
}

// ValidMAC reports whether messageMAC is a valid HMAC tag for message.
func ValidMAC(message, messageMAC, key []byte) bool {
    mac := hmac.New(sha256.New, key)
    mac.Write(message)
    expectedMAC := mac.Sum(nil)
    return hmac.Equal(messageMAC, expectedMAC)
}

hmac.New takes the hash constructor (sha256.New, not sha256.New()) and returns a hash.Hash. When the received tag arrives as hex, decode it with hex.DecodeString before calling hmac.Equal.

Typical use: webhook signatures

The common pattern is: the sender computes HMAC-SHA256 over the raw request body with a shared secret and puts the hex or Base64 tag in a header; the receiver recomputes it from the exact bytes it received and compares in constant time. Many providers also include a timestamp in the signed string so an intercepted request cannot be replayed later. The three mistakes that break verification in practice are parsing the JSON and re-serializing it before hashing (whitespace and key order change the bytes), using a UTF-8 key when the provider gave you a hex or Base64 key, and comparing strings with ==. The generic flow is described in the webhook guide.

Frequently Asked Questions

What is the difference between HMAC-SHA256 and SHA-256?

SHA-256 is a plain hash: anyone can compute it for any input, so it proves integrity but not origin. HMAC-SHA256 mixes a secret key into the hashing process (as defined in FIPS 198 / RFC 2104), so only parties holding the key can produce or verify the tag. Use SHA-256 for checksums and content addressing; use HMAC when you need to know that a message came from someone who knows the secret.

Why must I use a constant-time comparison to verify an HMAC?

A normal string comparison stops at the first mismatching byte, so the time it takes leaks how many leading bytes were correct. An attacker who can measure that can recover a valid tag byte by byte. Node provides crypto.timingSafeEqual, Python provides hmac.compare_digest, and Go provides hmac.Equal, the Go docs say explicitly to use Equal "in order to avoid timing side-channels".

Will Node.js, Python and Go produce the same HMAC-SHA256 output?

Yes, as long as the key bytes and message bytes are identical. HMAC is fully specified, so the same inputs produce the same 32-byte tag everywhere. Differences almost always come from encoding: a key read as UTF-8 vs hex, a JSON body re-serialized with different whitespace, or a trailing newline. Sign and verify the raw bytes exactly as transmitted.

Should the HMAC be sent as hex or Base64?

Either works; it is only an encoding of the same 32 bytes. Hex gives 64 characters and is easier to eyeball; Base64 gives 44 characters. Whatever you choose, compare the decoded bytes (or two strings in the same encoding and case) with a constant-time function, and document the format for the receiving side.

Try it in the browser

Use the Hash Generator to compute SHA-256 and other digests of text locally, handy for confirming that two services are hashing the same bytes before you debug the key.

Related guides