The problem
Adyen signs webhooks with HMAC-SHA256. Merchants copy a hex key from the Customer Area into their configuration, and the SDK's HmacValidator turns that key into bytes and recomputes the signature. In the Node library that conversion was Buffer.from(key, "hex").
Node's hex decoding does not fail on bad input. It stops at the first character that is not hex and returns what it has decoded so far. A key with a leading quote, a leading space or any other stray character in front therefore becomes a 0-byte key, and an odd-length key silently loses its last nibble.
That turns an ordinary configuration mistake into a forgeable signature check. From the issue:
const { createHmac } = require("crypto");
const { hmacValidator } = require("@adyen/api-library");
const key = "44782DEF547AAA06C910C43932B1EB0C71FC68D9D0C057550C48EC2ACF6BA056";
const configured = `"${key}"`; // quotes left in the env value
const payload = '{"type":"balancePlatform.accountHolder.updated"}';
const forged = createHmac("sha256", Buffer.alloc(0)).update(payload, "utf8").digest("base64");
new hmacValidator().validateHMACSignature(configured, forged, payload); // trueNothing errors. Real webhooks fail validation, because they were signed with the real key, while anyone who knows the payload format can compute a signature with an empty key that passes. The Go library returns the error from hex.DecodeString in this case, so the same key there fails loudly.
The issue also covered a smaller point in the comparison. secureCompare decoded both signatures from base64 before comparing them, and Node's base64 decoding ignores anything after the padding and skips characters it does not recognize. sig + "AAAA", sig + "!!" and the signature without its trailing = all validated. That is not a forgery, but a signature check should accept exactly one string.
What the maintainer said
An Adyen maintainer replied the next day and agreed with both changes: bad keys throw, and only the exact signature passes. They added one decision of their own: keys are not trimmed or cleaned up before the check, because none of the other Adyen libraries do that. A key with a stray trailing space or newline, which happens to work today, will start throwing. They asked for the deprecated validateBankingHMAC path to be covered too, and later asked for that behavior change to go into the release notes.
The Node fix
Pull request #1817 adds one helper that every key passes through:
private static keyFromHex(key: string): Buffer {
if (typeof key !== "string" || !/^(?:[0-9a-fA-F]{2})+$/.test(key)) {
throw new Error("Invalid HMAC key: expected a non-empty hex string with an even number of characters");
}
return Buffer.from(key, "hex");
}The comparison now works on the base64 strings themselves: a length check, then timingSafeEqual on their UTF-8 bytes. The length of a SHA-256 signature in base64 is public, so the early length check leaks nothing useful.
The new tests cover an empty key, a quoted key, a leading space, a trailing newline, an odd-length key and non-hex characters through every public entry point, check that the error message does not contain the key, and check that the forged signature from the issue is rejected. Thirty of them fail on main and pass with the change. An automated reviewer pointed out that a non-string signature, such as an object from a malformed payload, would reach Buffer.from and throw; the guard is now a type check, with tests for a number, an object and an array.
The same check in the other libraries
After opening the Node pull request I checked how the other Adyen libraries decode the key, and reported it in the issue: Python (binascii.a2b_hex), Go (hex.DecodeString), .NET and Ruby all accept an empty key and sign with it, so an empty environment variable ends up in the same place. .NET also pads an odd-length key with a 0 instead of failing. PHP and Java already reject an empty key. The maintainer asked for pull requests in Python, Go, .NET and Ruby. All four are open now.
Python
Pull request #515. a2b_hex("") returns b"", so an unset key gives signatures anyone can compute. Both signing functions now go through a small helper:
def _decode_hmac_key(hmac_key):
key = binascii.a2b_hex(hmac_key)
if not key:
raise ValueError("Must Provide a non-empty HMAC key")
return keyNon-hex and odd-length keys already raise from a2b_hex, so nothing else changes.
Java and PHP: the key in the error message
Java and PHP do reject a malformed key, but the exception message included it: Invalid Hex HMAC key: <key> in Java and Invalid HMAC key: $hmacKey in PHP. A key usually fails the check because of quotes or a stray character around the real key, so the message carries the secret into logs and error trackers. Java #2100 and PHP #976 replace the key with a description of what a valid key looks like and keep the message prefix, so code that matches on it keeps working.
In PHP the deprecated validateHMAC printed $hmacKey too, but because of swapped parameter names that variable actually holds the signature there.
Go
Pull request #607. hex.DecodeString("") returns an empty slice and no error, so the Go library failed loudly on a malformed key but not on a missing one. encode now returns an error for an empty key, which makes CalculateHmac fail and both validate functions return false, the same way they already treat a key that is not hex:
// An empty key would produce a signature anyone can compute.
if len(key) == 0 {
return "", fmt.Errorf("failed to generate HMAC: empty HMAC key")
}Ruby
Pull request #410. Ruby builds the key with [hmac_key].pack('H*'), which is even more forgiving than Node: an empty key becomes an empty string, and characters that are not hex are mapped to arbitrary nibbles, so a single space becomes "\x00". A new private helper checks the format before packing:
def hmac_key_bytes(hmac_key)
unless hmac_key.is_a?(String) && hmac_key.match?(/\A\h+\z/)
raise ArgumentError, 'HMAC key must be a non-empty hex string'
end
[hmac_key].pack('H*')
endA key with stray characters already produced the wrong signature for real webhooks, so rejecting it does not break a setup that works today.
.NET
Pull request #1899. Both hex helpers, HmacValidatorUtility.ConvertHexadecimalToBytes and HmacValidator.PackH, now throw ArgumentException for a null or empty key before any signing. They still pad an odd-length key with a 0. That is wrong in principle, but changing it could break existing setups that happen to work, so the pull request leaves it alone and says so.
Where things stand
The same gap turned up outside Adyen. The Svix PHP library decoded its webhook secret without checking the result, so an empty or invalid secret also became an empty HMAC key. That fix is merged.
Takeaways
- A hex or base64 decoder is not a validator. Check the format first and fail loudly; a key that silently decodes to fewer bytes is worse than one that throws.
- An HMAC with an empty key is still an HMAC. Reject empty keys explicitly, because the most common source of one is an unset environment variable.
- Compare the signature you received as you received it. Decoding it first widens what the check accepts.
- Do not put the value that failed validation into the error message when that value is a secret.
- When one SDK of a family has a gap, check its siblings. Here one issue turned into seven pull requests.
References
- IssueNode issue #1816: a malformed HMAC key is accepted as an empty key
- IssueMaintainer reply agreeing on the fix
- IssueThe other libraries, and the request for the same check there
- Pull requestNode pull request #1817
- Pull requestPython pull request #515
- Pull requestJava pull request #2100
- Pull requestPHP pull request #976
- Pull requestGo pull request #607
- Pull requestRuby pull request #410
- Pull request.NET pull request #1899
- DocumentationNode.js Buffer: decoding hex and base64
- DocumentationAdyen: verify HMAC signatures
Code and history behind this post
- comment Commented on [Bug]: HmacValidator accepts a malformed HMAC key as an empty key
- review Reviewed: #1817
- comment Commented on Adyen/adyen-node-api-library
- pull request Pull request #1817