Blog

How to generate HMAC signatures (SHA-256, SHA-512)

What HMAC is, how it differs from a plain hash, how APIs and webhooks use it, and how to generate and verify signatures in Node.js and Python.

February 10, 2026

Your webhook endpoint is a public URL. Anyone who finds it can send it a POST request that says {"event":"order.paid"}. So how does your server know the request really came from Stripe and not from someone hoping for free stuff? Almost always, the answer is an HMAC signature.

What HMAC is

HMAC (hash-based message authentication code) mixes a secret key into a cryptographic hash function such as SHA-256. The output is a short, fixed-size signature for a message. If the signature checks out, you know two things: the message hasn't been changed, and it was signed by someone who has the key.

HMAC is defined in RFC 2104 and is the standard way to sign API requests and webhooks. Stripe, GitHub, Shopify and Twilio all sign their webhooks with it.

HMAC vs a plain hash

A plain hash such as SHA-256 takes only the message. Anyone can compute it, so an attacker who edits the message can just recompute the hash and send that too. A plain hash catches accidental corruption, but it proves nothing about who sent the message.

HMAC also takes a secret key that only you and the sender know. Change one character of the message and the signature changes completely, and without the key nobody can work out the new one.

Text
SHA-256("Hello World")
= a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e

HMAC-SHA256(key = "my-secret-key", message = "Hello World")
= 2cd7c25025198d4458002ceb064ad37ccfbbe46650876d693f5e080bd954e449

You might wonder why HMAC doesn't just hash the key and message stuck together. With SHA-256, that shortcut is open to a length-extension attack: someone who sees one valid signature can append data to the message and compute a valid signature for the longer version, without knowing the key. HMAC avoids this by hashing twice, with an inner and an outer padded key:

Text
HMAC(K, m) = H((K' XOR opad) || H((K' XOR ipad) || m))

K    secret key
m    message
H    hash function (SHA-256, SHA-512, ...)
K'   K padded to the hash block size, or hashed first if it is longer
opad 0x5c repeated to the block size
ipad 0x36 repeated to the block size
||   concatenation

Choosing an algorithm

  • HMAC-SHA256 is the default choice. Stripe, GitHub and AWS Signature Version 4 use it. The output is 32 bytes (64 hex characters).
  • HMAC-SHA384 and HMAC-SHA512 produce 48 and 64 bytes (96 and 128 hex characters). They give a larger security margin, and SHA-512 is often faster than SHA-256 on 64-bit CPUs without SHA hardware instructions.
  • HMAC-SHA1 (40 hex characters) is still considered secure as an HMAC, even though SHA-1 collisions are practical. Some older APIs, including Twilio’s request signing, use it. Prefer SHA-256 for anything new.
  • HMAC-MD5 has no known practical attack as an HMAC, but there's no reason to choose it for new code.

How APIs use HMAC

  1. Share a secret. The provider generates a secret key and gives it to the client. Both sides store it securely.
  2. Sign the request. The sender builds a string to sign, usually from the HTTP method, path, a timestamp and the body, and computes its HMAC with the secret.
  3. Send the signature. The signature goes in a header such as X-Signature or Authorization.
  4. Verify it. The receiver rebuilds the same string, computes its own HMAC with the stored secret and compares the two. If they match, the request is accepted.

A typical signed webhook looks like this:

HTTP
POST /webhook HTTP/1.1
Content-Type: application/json
X-Signature: sha256=3f8a...
X-Timestamp: 1707580800

{"event":"order.completed","id":"ord_123"}

The server then:

  1. Reads the timestamp header.
  2. Rejects the request if the timestamp is more than about five minutes old, to block replays.
  3. Builds the signed string, for example timestamp + "." + body, using the raw body bytes.
  4. Computes HMAC-SHA256 of that string with the secret.
  5. Compares the result with the signature header using a constant-time comparison, and returns 401 if they differ.

Node.js example

The built-in crypto module has everything you need.

JavaScript
import crypto from "node:crypto";

const secret = "your-webhook-secret";
const payload = '{"event":"order.completed","id":"ord_123"}';

const signature = crypto
  .createHmac("sha256", secret)
  .update(payload)
  .digest("hex");

console.log(signature);
// c050d72df26a8e5a03d3e1d9b6dc8a27373cbfb2818e2b863b3af8996b71ff20

To verify, sign the raw request body. Re-serializing parsed JSON with JSON.stringify can change spacing or key order and produce a different signature.

JavaScript
import crypto from "node:crypto";
import express from "express";

function verifySignature(rawBody, receivedSig, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest();
  const received = Buffer.from(receivedSig.replace(/^sha256=/, ""), "hex");

  if (expected.length !== received.length) return false;
  return crypto.timingSafeEqual(expected, received);
}

const app = express();

// express.raw keeps the body as a Buffer instead of parsing it
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.get("x-signature") ?? "";
  if (!verifySignature(req.body, signature, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send("Invalid signature");
  }

  const event = JSON.parse(req.body.toString("utf8"));
  // Process the event...
  res.sendStatus(200);
});

Python example

The standard library has hmac and hashlib.

Python
import hashlib
import hmac
import os

from flask import Flask, abort, request

secret = b"your-webhook-secret"
payload = b'{"event":"order.completed","id":"ord_123"}'

print(hmac.new(secret, payload, hashlib.sha256).hexdigest())
# c050d72df26a8e5a03d3e1d9b6dc8a27373cbfb2818e2b863b3af8996b71ff20

# HMAC-SHA512 works the same way
print(hmac.new(secret, payload, hashlib.sha512).hexdigest())


def verify_signature(raw_body: bytes, received_sig: str, secret: bytes) -> bool:
    expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
    received = received_sig.removeprefix("sha256=")
    return hmac.compare_digest(expected, received)


app = Flask(__name__)
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"].encode()


@app.post("/webhook")
def webhook():
    signature = request.headers.get("X-Signature", "")
    # request.get_data() returns the raw body bytes
    if not verify_signature(request.get_data(), signature, WEBHOOK_SECRET):
        abort(401)

    event = request.get_json()
    # Process the event...
    return "OK", 200

Security best practices

  • Compare in constant time. Don't compare signatures with === or ==. Those return as soon as a character differs, and the timing difference can let an attacker guess a valid signature byte by byte. Use crypto.timingSafeEqual() in Node.js or hmac.compare_digest() in Python.
  • Sign a timestamp. Include a timestamp in the signed string and reject old requests, so a captured request can't be replayed later.
  • Sign the whole body. If only some fields are signed, an attacker can change the others without breaking the signature.
  • Keep secrets out of code. Load them from environment variables or a secrets manager such as AWS Secrets Manager or HashiCorp Vault, and never commit them.
  • Use long random keys. Generate keys with a cryptographically secure random generator, at least as long as the hash output: 32 bytes for SHA-256, 64 bytes for SHA-512.
  • Rotate keys. During a rotation, accept signatures made with either the old or the new key for a short overlap.
  • Monitor failures. A spike in failed verifications can point to an attack or a misconfigured sender.

Using the HMAC generator

Signature mismatches are a classic time sink: the code looks right, the secret looks right, and verification still fails. Computing the expected signature by hand tells you quickly whether the problem is the message, the key or the encoding. In the HMAC generator:

  1. Paste the exact message, for example the raw request body or your signed string.
  2. Enter the secret key. It's used as UTF-8 text.
  3. Choose HMAC-SHA256, HMAC-SHA384 or HMAC-SHA512.
  4. Choose Hex or Base64 output, to match the format the provider sends (Shopify, for example, sends Base64).
  5. Select Generate HMAC and copy the result.

The tool uses the browser’s Web Crypto API, so your secret key never leaves your machine. It doesn't support SHA-1 or MD5.

Need data from a webpage as JSON?

PulpMiner turns any public page into structured JSON and can save it as a live API. See it work on a demo page, no sign-up.

Try it on a demo page