Your HMAC is right on this page and wrong at the API, and the key encoding is usually why
A webhook arrives, your server computes a signature over the body, and the two strings do not match. The key is correct, the algorithm is correct, and the tag still differs on every character. Three details decide the answer before the hash function ever runs: what bytes your key really is, what bytes the message really is, and how the tag was written down.
What HMAC adds that a plain hash does not
Hash a message with SHA-256 and anyone holding the message reproduces the digest. That detects accidental corruption and nothing else. An attacker who rewrites the message rewrites the digest alongside it, and you have no way to tell.
HMAC folds a secret key into the hash, so only key holders produce a matching tag. The construction comes from RFC 2104 and looks like this:
HMAC(K, m) = H( (K' XOR opad) || H( (K' XOR ipad) || m ) )The two passes are the point. A single pass of key plus message, written as H(K || m), falls to length extension on SHA-1, SHA-256, and every other Merkle-Damgard hash. An attacker appends data to your message and computes a valid tag for the longer version without knowing the key. The nested structure closes that hole, which is why hand rolled signing schemes fail security review and HMAC does not.
Three encodings, and the mismatch that eats an afternoon
The key box above accepts text, hex, and base64 because APIs disagree about which one they hand you. The bytes differ wildly between readings of the same string.
| Key as written | Read as | Actual bytes | Length |
|---|---|---|---|
4a6f7368 | Text | 34 61 36 66 37 33 36 38 | 8 bytes |
4a6f7368 | Hex | 4a 6f 73 68 | 4 bytes |
Sm9zaA== | Text | 53 6d 39 7a 61 41 3d 3d | 8 bytes |
Sm9zaA== | Base64 | 4a 6f 73 68 | 4 bytes |
Two of those rows produce one tag. The other two produce tags with nothing in common. Read the API documentation for the wording it uses. Stripe and GitHub hand you a secret meant to be taken as literal text, AWS Signature Version 4 builds a chain of raw byte keys, and most Java examples calling Hex.decodeHex want the hex reading.
The panel on the right reports the decoded key length after every edit, so a 64 character hex secret showing 32 bytes tells you the hex mode took, and 64 bytes tells you it did not.
The message has to be the exact bytes, not the pretty version
Signature checks fail on message handling more often than on key handling, and the failures share a shape: the sender signed raw bytes, the receiver signed something a framework reshaped on the way in.
- Parsed then re-serialized JSON. Reading the request body into an object and dumping it back changes key order, spacing, and unicode escapes. Sign the raw body string before any parser touches it. In Express, capture
req.rawBodyinside the verify hook. In Laravel, read$request->getContent()rather than$request->all(). - Trailing newlines. A payload saved from a text editor and the same payload sent by
curldiffer by one byte when the editor appends a final newline. That single byte changes every character of the tag. - Line endings. A file written on Windows carries CRLF. The same file from a Linux CI runner carries LF. Both look identical in a diff view and sign differently.
- Character encoding. A name with an accent is two bytes in UTF-8 and one byte in Latin-1. This page reads your message as UTF-8, matching what browsers, JSON, and modern APIs send.
When a text payload refuses to match, drop the actual file into the box above. The file path reads raw bytes with no text interpretation at all, which settles the newline and encoding questions in one step.
Check yourself against the published test vectors
Before blaming the other side, prove your implementation on inputs with known answers. RFC 4231 case 1 uses a key of twenty 0x0b bytes and the message Hi There, and RFC 2202 covers the older digests. Press Load RFC test vector to fill the console with the matching case for whichever algorithm is selected.
| Algorithm | Key | Expected tag in hex |
|---|---|---|
| HMAC-SHA256 | 0x0b repeated 20 times | b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7 |
| HMAC-SHA512 | 0x0b repeated 20 times | 87aa7cdea5ef619d4ff0b4241a1d6cb02379f4e2ce4ec2787ad0b30545e17cdedaa833b7d6b8a702038b274eaea3f4e4be9d914eeb61f1702e696c203a126854 |
| HMAC-SHA1 | 0x0b repeated 20 times | b617318655057264e28bc0b6fb378c8ef146be00 |
| HMAC-MD5 | 0x0b repeated 16 times | 9294727a3638bb1c13f48ef8158bfc9d |
Match those and your reading of key and message is sound. Keep failing against a partner API afterwards and the disagreement sits in what gets signed, not in the hashing.
Compare tags in constant time, never with a string equality operator
Verify mode above reports a match or a mismatch and stops there, on purpose. It never names the character where the two diverge, because a comparison quitting at the first wrong byte gives an attacker a timing signal. Feed a server a million guesses, measure microseconds, and the tag falls out one byte at a time. Real servers use a comparison reading every byte regardless:
// PHP
$expected = hash_hmac('sha256', $request->getContent(), $secret);if (!hash_equals($expected, $signature)) {abort(403);}// Node
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();const given = Buffer.from(signature, 'hex');if (expected.length !== given.length || !crypto.timingSafeEqual(expected, given)) {return res.status(403).end();}# Python
expected = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()if not hmac.compare_digest(expected, signature):abort(403)Note the length check ahead of timingSafeEqual in the Node version. That function throws on mismatched lengths rather than returning false, and an unhandled throw inside a webhook handler becomes a 500 instead of a clean rejection.
Which digest to sign with
| Algorithm | Tag size | Block size | Where it stands |
|---|---|---|---|
| HMAC-SHA256 | 256 bit | 64 bytes | The default for new work. Webhook signatures, API request signing, and JWT HS256 all land here. |
| HMAC-SHA512 | 512 bit | 128 bytes | Faster than SHA-256 on 64 bit CPUs for long messages. The wider tag adds little, since 256 bits already sits past reach. |
| HMAC-SHA384 | 384 bit | 128 bytes | SHA-512 truncated. Shows up in TLS cipher suites and in JWT HS384. |
| HMAC-SHA224 | 224 bit | 64 bytes | Rare outside compliance profiles asking for one specific tag width. |
| HMAC-SHA1 | 160 bit | 64 bytes | The collision attacks on SHA-1 do not break HMAC-SHA1, and it holds up in practice. Auditors flag it anyway, so treat it as a legacy option for AWS Signature Version 2 and older OAuth 1.0a partners. |
| HMAC-MD5 | 128 bit | 64 bytes | Same story with a thinner margin. Kept here for reading old systems, never for new ones. |
The collision weaknesses in MD5 and SHA-1 attack a different property than HMAC leans on, which is why no practical forgery exists against either construction. Picking them today still means explaining that distinction to every reviewer who greps the codebase, so choose SHA-256 and skip the conversation.
Key length, and what happens at the edges
HMAC takes a key of any length, then normalizes it against the hash block size before use. Two edges are worth knowing about.
- Longer than the block. A 100 byte key for HMAC-SHA256 gets hashed down to 32 bytes first, then padded back out to 64. Two different 100 byte keys sharing a SHA-256 digest would produce the same tag, which stays theoretical, though it does mean key length past the block size buys nothing.
- Shorter than the tag. A 4 byte key is padded with zero bytes to 64 and works fine mechanically. Security tracks key entropy rather than padded length, so a short or guessable key falls to an offline attack against one captured message. RFC 2104 asks for a key at least as long as the digest, which means 32 random bytes for SHA-256.
The facts panel reports which branch your key falls into after each edit, including the hash step when the key runs oversized.
Where this tool stops
- No HMAC-SHA3The CryptoJS library behind this page implements HMAC over MD5, SHA-1, and the SHA-2 family only. SHA-3 based HMAC and KMAC need a different library.
- Files stay in memoryA dropped file is read whole into a browser buffer. A few hundred megabytes works on a desktop and fails on a phone. For anything larger, run
openssl dgst -sha256 -hmac 'key' fileinstead. - No key derivationWhat you type is what gets used. HKDF, PBKDF2, and the AWS Signature Version 4 key chain all derive a signing key through several HMAC rounds, and none of that happens here.
- Tags are never truncatedProtocols using a shortened tag, such as the 96 bit variants in IPsec, need the output cut to length by you. The full tag is what appears above.
- Nothing is storedKey, message, and result live in the page and disappear on reload. Rotate any production secret you paste into a browser, here or anywhere, since it has already touched your clipboard.
All hashing happens in your tab through the CryptoJS file loaded with the page. Open the network panel and press Generate HMAC to confirm no request leaves during signing.
