About HMAC-SHA256
HMAC-SHA-256 proves two things at once: that a message has not been altered, and that whoever produced the code held the shared secret. It takes a key and a message and returns a 256-bit code (64 hex characters) that nobody can produce or verify without the key.
The construction is deliberately not a hash of the key and message concatenated. HMAC hashes the message twice using two different key-derived pads, defined in RFC 2104, and that is what makes it immune to the length-extension attack which breaks the naive hash(secret + message) approach. If you have ever written that shortcut, HMAC is the thing to replace it with.
HMAC-SHA256 is the one you will meet in practice. Stripe, GitHub, Shopify, Slack, Twilio and almost every other provider signs its webhooks with it; it is the HS256 in JWT headers; and it is the signing primitive inside AWS Signature v4. If a provider's documentation says only "HMAC", this is what it means.
Any byte string works as a key. There is no length requirement, but a key shorter than the digest gives away security for nothing, so generate 32 random bytes and store them as a secret rather than typing a passphrase.
Both the key and the message stay in your browser on this page. The code is computed with the Web Crypto API and never transmitted, which is the only safe way to paste a real signing secret into a web tool.
How to use the HMAC-SHA256
- Paste the message exactly as the signing side sees it, byte for byte, including any newlines - this is where most mismatches come from.
- Paste the shared secret into the key field.
- The HMAC-SHA-256 code is computed in your browser and shown as 64 hex characters.
- Compare it against the signature you received. In your own code, compare with a constant-time function, never with ==.
Examples
-
Message
request-body
HMAC-SHA256 in code
The same operation this tool performs, in the languages you are most likely to need it.
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode("my-shared-secret"),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode("message"));
const hex = [...new Uint8Array(sig)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
console.log(hex);
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, receivedHex, secret) {
const expected = createHmac("sha256", secret).update(rawBody).digest();
const received = Buffer.from(receivedHex, "hex");
// Lengths must match first - timingSafeEqual throws if they differ.
if (received.length !== expected.length) return false;
return timingSafeEqual(expected, received);
}
// rawBody MUST be the unparsed body. Re-serialising req.body with
// JSON.stringify changes key order and spacing, and verification fails.
import hmac, hashlib
def sign(secret: bytes, message: bytes) -> str:
return hmac.new(secret, message, hashlib.sha256).hexdigest()
def verify(secret: bytes, message: bytes, received: str) -> bool:
expected = sign(secret, message)
# compare_digest is constant-time; == is not
return hmac.compare_digest(expected, received)
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
"my-shared-secret".getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] sig = mac.doFinal("message".getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : sig) sb.append(String.format("%02x", b));
System.out.println(sb);
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
)
func main() {
mac := hmac.New(sha256.New, []byte("my-shared-secret"))
mac.Write([]byte("message"))
fmt.Println(hex.EncodeToString(mac.Sum(nil)))
// Verify with hmac.Equal, which is constant-time.
}
using System.Security.Cryptography;
using System.Text;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes("my-shared-secret"));
byte[] sig = hmac.ComputeHash(Encoding.UTF8.GetBytes("message"));
Console.WriteLine(Convert.ToHexString(sig).ToLowerInvariant());
printf '%s' "message" \
| openssl dgst -sha256 -hmac "my-shared-secret" -hex
# printf '%s' rather than echo: echo appends a newline,
# which changes the signature.
When you need this
- Verifying a Stripe, GitHub or Shopify webhook signature by hand to find out why your handler rejects it.
- Reproducing the signature of an HS256 JWT to confirm you have the right secret.
- Building the signing step of an AWS Signature v4 request.
- Signing an outbound request for an API that requires HMAC request signing.
- Checking your implementation against a known-good value while porting signing code.
Common problems and what causes them
- GitHub's sha256= prefix compared as part of the signature
- GitHub sends X-Hub-Signature-256 as "sha256=" followed by 64 hex characters. Comparing the whole header against your bare hex digest always fails. Strip the prefix, or prepend it to your own value before comparing.
- Stripe's signature header containing several values
- Stripe-Signature carries a timestamp and one or more v1 signatures, comma-separated. You must extract the timestamp, build the signed payload as timestamp + "." + rawBody, and compare against each v1 value - a naive parse that takes the whole header fails.
- Signature mismatch on a webhook the provider says is valid
- You are almost certainly signing a re-serialised body. Frameworks parse JSON and re-encode it, changing key order, whitespace and unicode escaping. Capture the raw request body before any body-parser middleware runs, and sign those exact bytes.
- Hex vs Base64 signature encoding
- The same 256-bit code prints as 64 hex characters or as Base64. GitHub sends hex with a "sha256=" prefix; several other providers send Base64. Decode both sides to bytes before comparing.
- Key treated as text when the provider means bytes
- If a secret is given to you as hex or Base64, decode it to bytes before using it as the key. Passing the printable string as UTF-8 produces a different key and therefore a different code.
- Comparing signatures with == or ===
- A normal string comparison returns as soon as it finds a difference, leaking how much of the signature was correct and enabling byte-at-a-time forgery over many attempts. Use timingSafeEqual, hmac.compare_digest, hmac.Equal or MessageDigest.isEqual.
- Using a plain hash where a MAC is required
- sha256(secret + message) is not a MAC. Because SHA-256 is a Merkle-Damgard construction, an attacker holding one valid digest can append data and compute a valid digest for the longer message without knowing the secret. HMAC exists to prevent exactly that.
- A trailing newline in the message
- A shell heredoc, a file read, or echo will usually add \n where the signing side did not. If your code is close but wrong, try again without the final newline.
FAQ
- Which HMAC do webhook providers actually use?
- HMAC-SHA256, almost universally - Stripe, GitHub, Shopify, Slack and Twilio all use it. The differences between providers are in the canonical string and the encoding, not the algorithm, which is why signatures fail even when everyone agrees on SHA-256.
- Is HMAC-SHA256 the same as the HS256 in a JWT?
- Yes. A JWT with alg HS256 is signed by HMAC-SHA256 over the Base64URL header and payload joined by a dot. You can reproduce a JWT's signature on this page by signing that exact string with the shared secret.
- What is the difference between SHA-256 and HMAC-SHA-256?
- SHA-256 is a plain hash - anyone can compute it for any input, so it only tells you whether data changed by accident. HMAC-SHA-256 mixes in a secret key, so only someone holding the key can produce or check the code. That is what makes it proof of origin as well as integrity.
- How long should the secret key be?
- Any length is accepted, but use at least 32 random bytes so the key matches the digest size. Keys longer than the hash block get hashed down first, so extreme length adds nothing. Generate it randomly - a human-chosen passphrase is guessable.
- Why does my HMAC differ from the server's for the same message?
- In order of likelihood: the body was re-serialised instead of signed raw; there is a trailing newline; the signature is Base64 on one side and hex on the other; or the key was used as text when it should have been decoded from hex or Base64.
- Can an HMAC be reversed to recover the key or the message?
- No - it is built on a one-way hash and has no inverse. A weak, guessable key can be brute-forced offline by an attacker holding one message and its code, which is the reason to use random keys.
- Is HMAC-SHA-256 still considered secure?
- Yes. HMAC's security does not rest on the collision resistance of its hash, which is why even HMAC-SHA1 has no practical break. HMAC-SHA-256 is a current, recommended choice.
- Is my secret key safe to paste here?
- The computation runs in your browser through the Web Crypto API; the key and message are never transmitted or stored. You can confirm that in your network tab, or load the page once and then use it offline.
- Where do I put the secret?
- Use the Key / passphrase field - it never leaves your browser.
Related reading
- What is HMAC?
- API signature tester sign a full request
- JWT verifier verify an HS256 token