HMAC Signature Example: Generate and Verify HMAC-SHA256 in Any Language

Quick answer

💡An HMAC-SHA256 signature is computed by running SHA-256 over the concatenation of a padded secret key and your message data. To generate one in Node.js: crypto.createHmac('sha256', secret).update(data).digest('hex'). Always compare signatures with a timing-safe function like crypto.timingSafeEqual(), never with ===.

Error symptoms

  • Signature mismatch even when the secret looks correct
  • Webhook verification fails in production but passes locally
  • TypeError: Data must be a string or a buffer when calling createHmac
  • Computed HMAC differs between Node.js and Python for the same input
  • 401 Unauthorized from webhook endpoint despite a valid payload
  • hmac.compare_digest() returns False when signatures look identical in logs

Common causes

  • Signing parsed JSON body instead of the raw request bytes
  • Passing the secret as a hex or base64 string without decoding it to raw bytes first
  • Using plain SHA-256(key + message) instead of the HMAC construction
  • String equality (===) instead of constant-time comparison
  • Forgetting to include the provider timestamp in the signed payload
  • Newline or encoding differences between the signing and verifying sides

When it happens

  • Receiving webhooks from Stripe, GitHub, or Slack
  • Service-to-service request signing in a microservices architecture
  • Rotating HMAC secrets without a transition window
  • Serverless functions that lack access to the raw request body stream
  • Porting a webhook handler between languages or frameworks

How HMAC combines a hash function with a secret key

HMAC stands for Hash-based Message Authentication Code. At its core, HMAC is a construction that takes any cryptographic hash function — SHA-256, SHA-512, SHA-1 — and combines it with a shared secret key to produce a message authentication code. Unlike a plain hash, which anyone can compute for any input, an HMAC can only be produced by someone who knows the secret key. This makes it suitable for verifying both the integrity of a message (it has not been altered in transit) and its authenticity (it was produced by a party that holds the key).

The construction is defined in RFC 2104 and works in two nested hash passes. First, the key is padded or hashed to match the hash function's block size — 64 bytes for SHA-256. The padded key is then XORed with two fixed constants: the inner padding (ipad, byte 0x36 repeated 64 times) and the outer padding (opad, byte 0x5c repeated 64 times). The inner hash is computed as SHA-256(key XOR ipad concatenated with the message bytes). The outer hash is SHA-256(key XOR opad concatenated with the inner hash output). This two-layer structure prevents a class of vulnerability called length-extension attacks, which allow an attacker to forge new valid digests if a single-pass construction like SHA-256(key || message) is used instead.

Because HMAC uses a symmetric key, both the sender and receiver must possess the same secret. The sender computes the HMAC over the message and transmits both the message and the resulting tag. The receiver independently computes the HMAC over the received message using their copy of the shared key, then compares the computed result to the tag that arrived. If the tags match, the message was produced by someone with access to the secret and has not been altered.

The security of HMAC depends on the secrecy of the key and the collision resistance of the underlying hash function. HMAC-SHA256 uses SHA-256, which produces a 256-bit output and has no known practical attacks as of 2026. HMAC-MD5 and HMAC-SHA1 still appear in legacy systems but should be avoided in new code. SHA-1's known weaknesses are harder to exploit inside the HMAC construction, but the prudent position is to migrate to SHA-256 wherever the overhead is acceptable.

Key length matters in practice. A key shorter than 32 bytes (the SHA-256 output length) weakens security proportionally. If the key exceeds 64 bytes (the SHA-256 block size), HMAC hashes it down to 32 bytes automatically. The practical recommendation is to use exactly 32 bytes of cryptographically random data generated with a CSPRNG: crypto.randomBytes(32) in Node.js or os.urandom(32) in Python. Human-readable passphrases have far lower entropy per byte and are not suitable as HMAC keys without a dedicated key derivation function like PBKDF2.

Never use a plain hash as a MAC

SHA-256(key + message) is not a secure message authentication code. It is vulnerable to length-extension attacks that let an attacker append data and produce a valid digest without knowing the key. Always use the HMAC construction via crypto.createHmac() in Node.js or Python's hmac module.

Generating an HMAC-SHA256 signature in Node.js and Python

In Node.js, the built-in crypto module provides createHmac(), which accepts the algorithm name and the secret key and returns an HMAC object. You call .update() with the message — a string, Buffer, or TypedArray — then .digest() with the output encoding. The two most common encodings are 'hex' (a 64-character lowercase string) and 'base64' (a 44-character string). The choice does not affect security; it is driven entirely by what the receiving system expects.

The update() method can be called multiple times to feed data incrementally. All successive update() calls are equivalent to one call with the concatenation of all inputs. This is useful for streaming large HTTP request bodies: pipe the readable stream through the HMAC object as a Transform and call digest() only after the stream ends, keeping memory consumption flat rather than loading the full body at once.

In Python, the standard library's hmac module provides an equivalent interface. Create an HMAC object with hmac.new(key, msg, digestmod), where key must be a bytes object, not a str. Call .hexdigest() for hex output or .digest() for raw bytes. Passing a Python str as the key raises a TypeError in Python 3. Always encode string secrets first: secret.encode('utf-8'). If you need the UTF-8 encoding of the message too, call message.encode('utf-8') before passing it to hmac.new().

One important subtlety across languages is whether the secret is passed as raw bytes or as an encoded string representation. In Node.js, if you call crypto.createHmac('sha256', hexSecret), the hexSecret string is used as literal ASCII characters — not as the bytes it encodes. A 32-byte secret stored as a 64-character hex string becomes a 64-byte HMAC key if passed without decoding. If the other party decodes first, both sides end up with different effective keys and different HMAC outputs, with no error thrown by either side. Fix it by decoding explicitly: Buffer.from(hexSecret, 'hex').

For performance-critical applications, the overhead of createHmac() is negligible compared to network I/O in typical web servers. In Python, you can create a base HMAC object with only the key and call .copy() before each update() to reuse the key-setup work across many messages. In Node.js there is no official clone API, but the throughput of the native OpenSSL-backed implementation is already in the gigabytes-per-second range, making this optimization unnecessary for ordinary web workloads.

Example 1

Generate a hex HMAC-SHA256 signature and verify it with constant-time comparison to prevent timing attacks.

HMAC-SHA256 in Node.js with timing-safe verification

❌ Wrong

const crypto = require('crypto');

const secret = 'my-webhook-secret';
// Wrong: using parsed/re-serialized body instead of raw bytes
const payload = JSON.stringify(req.body);

const sig = crypto.createHmac('sha256', secret)
  .update(payload)
  .digest('hex');

// Insecure: short-circuits on first differing character
if (sig === req.headers['x-signature']) {
  processWebhook(req.body);
}

✅ Fixed

const crypto = require('crypto');

// rawBody requires express.raw({ type: 'application/json' }) on this route
const secret = Buffer.from(process.env.HMAC_SECRET, 'base64');
const rawBody = req.rawBody;

const expected = crypto
  .createHmac('sha256', secret)
  .update(rawBody)
  .digest();

const received = Buffer.from(
  req.headers['x-signature'] ?? '', 'hex'
);

// Check lengths first — timingSafeEqual throws if they differ
if (
  received.length === expected.length &&
  crypto.timingSafeEqual(expected, received)
) {
  processWebhook(JSON.parse(rawBody));
} else {
  res.status(401).json({ error: 'Invalid signature' });
}

The broken version re-serializes parsed JSON (which may differ from original bytes), compares with === (vulnerable to timing attacks), and passes the secret as raw ASCII without decoding. The fixed version captures raw bytes before parsing, decodes the secret from base64, and uses crypto.timingSafeEqual() with an explicit length pre-check.

Verifying webhook signatures from Stripe, GitHub, and Slack

Webhook providers use HMAC signatures to prove that a payload originated from their servers and has not been tampered with in transit. The general pattern is consistent: they compute an HMAC over the raw request body using a secret you configure in their dashboard, then place the computed signature in a custom HTTP header. Your server recomputes the HMAC from the received body and compares it to the header value using a constant-time comparison function.

Stripe places the signature in the Stripe-Signature header in the format t=timestamp,v1=hex_signature. The signed content is the timestamp, a literal period, then the raw request body: timestamp + '.' + rawBody. The timestamp is included to prevent replay attacks. You should reject any webhook with a timestamp older than five minutes even if the signature is cryptographically valid. In a manual implementation, parse the t= and v1= values from the header, construct the signed string, compute HMAC-SHA256 over it, then compare with crypto.timingSafeEqual().

GitHub places the signature in X-Hub-Signature-256 as sha256=hex_signature. The signed content is the raw request body with no additional metadata. GitHub signs the exact bytes it sends, so you must operate on the raw body buffer before any JSON parsing. A common and silent mistake is computing HMAC over JSON.stringify(req.body) after Express has already deserialized the body — the reserialized JSON may differ in whitespace or key order, producing a different HMAC and a failed verification.

Slack uses X-Slack-Signature formatted as v0=hex_signature with a companion X-Slack-Request-Timestamp header. The signed string is the version prefix, the timestamp, and the raw body joined by colons: 'v0:' + timestamp + ':' + rawBody. Slack also recommends the five-minute timestamp window for replay protection.

The critical implementation detail across all providers is raw body capture before any middleware. In Express.js, configure express.raw({ type: 'application/json' }) on the specific webhook route, not the global express.json() handler. In Next.js API routes, export config with api.bodyParser = false and read the stream manually. In AWS Lambda with function URLs, verify that base64 decoding is configured at the infrastructure level. Failing to capture the raw bytes is the single most common cause of production webhook verification failures, and it produces no runtime error — the HMAC simply mismatches silently.

Capture the raw body before any parsing middleware

In Express.js use express.raw({ type: 'application/json' }) on the webhook route specifically. In Next.js export api.bodyParser = false. In AWS Lambda configure function URL base64 decoding at the infrastructure level. Parsing the body before computing HMAC is the most common cause of silent webhook signature failures.

Timing-safe comparison and why string equality is insecure

After computing the expected HMAC, you must compare it to the signature the sender provided. The intuitive approach — if (computedSig === receivedSig) — is insecure for cryptographic comparisons. JavaScript's === operator, and most string equality implementations, short-circuits as soon as it finds the first differing character. An attacker who can measure response latency can exploit this to determine the expected signature one character at a time.

The attack proceeds systematically. The attacker sends fake signatures that share an increasing prefix with the correct signature, measuring how long your server takes to reject each one. When the first character of the guess is correct, the comparison runs slightly longer before short-circuiting, because it has to evaluate the second character. Over statistically averaged repeated requests, this timing difference — often just tens of nanoseconds — becomes measurable. For a 64-character hex HMAC-SHA256 output, the full search space is reduced from 16^64 to roughly 64 * 16 = 1024 probes. While network jitter complicates the attack from a remote internet adversary, it is well-documented and practical against same-datacenter adversaries or low-latency networks.

The defense is a constant-time comparison that always inspects every byte regardless of where the first mismatch occurs. In Node.js, use crypto.timingSafeEqual(bufferA, bufferB). Both arguments must be Buffer or TypedArray instances. If comparing hex strings, convert first: crypto.timingSafeEqual(Buffer.from(computed, 'hex'), Buffer.from(received, 'hex')). If the byte lengths differ, timingSafeEqual throws a TypeError rather than returning false — check lengths separately before calling it, since the length difference itself leaks no useful information when handled as a pre-check boolean.

In Python, use hmac.compare_digest(a, b) from the standard library, available since Python 2.7.7 and 3.3. It accepts both bytes and str and is guaranteed to run in time proportional to input length rather than position of the first differing byte. For environments without compare_digest, implement constant-time comparison by XORing each byte pair and ORing all results: equality holds only when the final OR is zero.

Beyond timing, validate that the signature header is present and non-empty before attempting any cryptographic operation. A missing or empty header must be rejected immediately without invoking the comparison function. Log the rejection with sanitized metadata — request ID, timestamp, endpoint — but never log the secret key, the expected HMAC value, or any equivalent of the secret.

Example 2

Verify a Stripe-style webhook signature that includes a timestamp to block replay attacks.

HMAC-SHA256 webhook verification in Python with replay protection

❌ Wrong

import hmac, hashlib

def verify_webhook(payload: str, signature: str, secret: str) -> bool:
    # Wrong: secret is str, not decoded from base64 to bytes
    expected = hmac.new(
        secret.encode(),
        payload.encode(),
        hashlib.sha256
    ).hexdigest()
    # Wrong: == operator is not constant-time
    return expected == signature

✅ Fixed

import hmac, hashlib, base64, time

REPLAY_TOLERANCE_SECONDS = 300

def verify_webhook(
    raw_body: bytes,
    sig_header: str,
    b64_secret: str
) -> bool:
    secret = base64.b64decode(b64_secret)

    # Parse Stripe-style: t=timestamp,v1=hexsig
    parts = dict(p.split('=', 1) for p in sig_header.split(','))
    timestamp = parts.get('t', '')
    received_sig = parts.get('v1', '')

    if not timestamp.isdigit():
        return False
    if abs(int(time.time()) - int(timestamp)) > REPLAY_TOLERANCE_SECONDS:
        return False

    signed_payload = f'{timestamp}.'.encode() + raw_body
    expected = hmac.new(
        secret, signed_payload, hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected, received_sig)

The fixed version decodes the base64 secret to raw bytes, constructs the timestamp-prefixed signed payload matching Stripe's format, rejects requests older than five minutes to block replays, and uses hmac.compare_digest() for constant-time comparison instead of ==.

Key encoding: hex, base64, and raw bytes in HMAC contexts

A frequent source of cross-system HMAC mismatches is ambiguity about how the secret key is encoded. The HMAC algorithm operates on raw bytes internally, so the practical question is always: are the bytes passed to the HMAC constructor the actual secret bytes, or are they the ASCII character bytes of a hex or base64 representation of the secret?

Consider a 32-byte secret. Stored as a hex string, it becomes 64 ASCII characters — 64 bytes when handed directly to HMAC without decoding. Stored as base64, it becomes 44 characters — 44 bytes. Stored as raw binary, it is 32 bytes. All three representations encode the same underlying secret, but each produces a completely different HMAC output when passed without decoding to the hash function. No error is thrown; both the signing and verifying sides compute valid but different MACs, causing every verification attempt to fail silently.

Webhook providers document their key handling requirements precisely. Stripe webhook secrets begin with the prefix whsec_ followed by a base64-encoded value — strip the prefix and base64-decode the remainder before using it as the HMAC key. GitHub webhook secrets are raw strings you choose in the dashboard and are passed as UTF-8 bytes directly to HMAC without any additional encoding step. Slack follows the same raw-string convention. Check each provider's developer documentation rather than guessing, because both conventions are widely deployed.

For inter-service HMAC where you control both sides, establish an explicit convention at the start of the project. A practical and portable convention is to generate keys as random bytes, encode them as base64 for storage in environment variables or a secrets manager, and always decode to raw bytes before use: Buffer.from(process.env.HMAC_SECRET, 'base64') in Node.js or base64.b64decode(os.environ['HMAC_SECRET']) in Python. Place a comment next to the decoding call explaining why it exists so future maintainers understand and do not remove it as seemingly redundant.

Output encoding is simpler and independent of security. Hex produces a 64-character lowercase string that is easy to inspect in logs. Base64 produces 44 characters (33% shorter) and is preferred when the signature must fit within size-constrained HTTP headers or cookies. URL-safe base64 replaces + with - and / with _, which matters if the signature appears directly in a URL query parameter without percent-encoding. Some webhook providers targeting mobile or IoT use URL-safe base64; others use standard base64. Always confirm the expected format in the provider specification before implementing.

HMAC vs digital signatures: when symmetric keys are sufficient

HMAC and digital signatures both authenticate data, but they differ fundamentally in key model and the guarantees they provide. HMAC uses a symmetric key: the same secret generates and verifies the tag, meaning both parties must trust each other equally with the same material. Digital signatures — RSA-PSS, ECDSA, Ed25519 — use asymmetric key pairs: a private key generates the signature and only the corresponding public key is needed to verify it, without exposing the private key to verifiers.

For communication between two mutually trusted parties — a server and its webhook consumers, two microservices in the same infrastructure boundary, or an internal signing service and its downstream validators — HMAC is simpler, faster, and fully sufficient. The webhook model is a canonical example: you generate a secret, share it with your consumer, and the consumer uses it only for verification. Speed is a real advantage at scale: HMAC-SHA256 processes data in the gigabytes-per-second range on modern hardware. RSA signing, which requires modular exponentiation over 2048-bit numbers, is hundreds of times slower for the signing operation alone.

Digital signatures become necessary when you need non-repudiation — the ability to prove to a neutral third party that a specific entity signed a specific message, without that party sharing any secret material. Because only the signer knows the private key, a valid signature proves authorship. Any verifier who holds only the public key can confirm this without gaining the ability to forge new signatures. HMAC provides no non-repudiation: every party with the shared secret can produce a valid tag, so in a dispute you cannot establish which party created a given message.

JWT formalizes both models. HS256 uses HMAC-SHA256 with a shared secret, suitable for a single service that issues and validates its own tokens. RS256 and ES256 use RSA and ECDSA respectively, suitable for federated identity scenarios where a separate identity provider signs tokens and multiple independent relying parties verify them without any shared secret. Choosing HS256 in a multi-service scenario is a frequently made and significant security mistake, because every service that verifies tokens also gains the ability to issue arbitrary new ones.

The practical decision rule is direct. Choose HMAC when you control both the signing and verification environments and both are equally trusted. Choose digital signatures when verification happens in an untrusted or public context, when non-repudiation is required for audit or compliance purposes, or when multiple independent parties must verify tokens without any of them gaining the ability to forge.

Frequently asked questions

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

SHA-256 is a public one-way hash that anyone can compute for any input without a secret. HMAC-SHA256 incorporates a secret key through a two-layer nested hash construction defined in RFC 2104. Only someone who possesses the secret key can produce or verify the resulting tag. This makes HMAC suitable for authentication, while plain SHA-256 only produces a tamper-evident fingerprint that provides no authentication guarantee.

Why must I use the raw request body for webhook signature verification?

HMAC operates on exact bytes. When a framework parses a JSON body and you re-serialize it with JSON.stringify(), key ordering, whitespace, or number formatting may differ from the bytes the provider originally signed. Even a single extra space produces a different HMAC output. Capture the body as a raw Buffer before any parsing middleware runs and compute HMAC over those exact original bytes.

Can I use HMAC-SHA1 instead of HMAC-SHA256?

HMAC-SHA1 remains computationally secure in the HMAC construction as of 2026 because SHA-1's collision attacks are much harder to exploit within the nested HMAC structure. However, HMAC-SHA256 is strongly preferred for all new implementations. Several standards bodies now deprecate SHA-1 for all uses, and HMAC-SHA256 has no meaningful performance disadvantage on modern hardware.

How long should my HMAC secret key be?

Use at least 32 bytes of cryptographically random data generated by a CSPRNG: crypto.randomBytes(32) in Node.js or os.urandom(32) in Python. Keys shorter than the hash output length (32 bytes for SHA-256) reduce security proportionally. Store the key base64-encoded in a secrets manager or environment variable. Human-readable passphrases are not suitable as HMAC keys without a key derivation function like PBKDF2.

What is a timing attack and does it matter for webhook verification in practice?

A timing attack exploits short-circuit string comparison to recover the expected signature one character at a time by measuring response latency differences across many requests. It is more practical against same-datacenter adversaries than internet attackers facing network jitter. Using crypto.timingSafeEqual() or hmac.compare_digest() eliminates the attack entirely and costs almost nothing, so there is no reason not to use it.

Does HMAC protect against replay attacks on its own?

No. HMAC verifies that a payload was produced by a holder of the shared secret and has not been modified, but it does not prevent an attacker from capturing a valid signed request and replaying it later. To block replays, include a timestamp or single-use nonce in the signed payload and reject requests with timestamps older than a few minutes, as Stripe and Slack both do.

Should I use HMAC or JWT with RS256 for service-to-service authentication?

Use HMAC (JWT HS256) when a single service both issues and verifies its own tokens and all verifying parties are equally trusted. Use JWT with RS256 or ES256 when multiple independent services verify tokens without sharing a private key, because HS256 grants every verifier the ability to forge new tokens — a serious security boundary violation in multi-tenant or multi-service architectures.

What output encoding should I use for the HMAC — hex or base64?

Both are equally secure representations of the same output bytes. Hex produces a 64-character lowercase string that is easier to read and compare in logs. Base64 produces 44 characters (33% shorter) and is preferred when the signature must fit in size-constrained HTTP headers or cookies. Use URL-safe base64 (replacing + with - and / with _) if the signature appears directly in a URL query parameter.

Why does my HMAC output differ between Node.js and Python for the same secret and message?

The most common cause is key encoding mismatch: one side passes the secret as raw bytes while the other passes the hex or base64 string as-is without decoding. Confirm that both sides decode the secret to the same raw bytes before creating the HMAC object. Also check that the message bytes are identical — trailing newlines, different JSON key ordering, or encoding differences all produce different HMAC outputs.

Related guides

All tools run in your browser. Your data never leaves your device. Last updated: 2026-05-07.