Two tenants, two cache entries, one digest. The hash function did its job perfectly — the key you fed it was ambiguous.
1. Concatenation erases field boundaries
The classic cache key:
sha256(tenant + user_id)
Now try two unrelated records:
sha256("acme" + "42") # 7c187954…3c1fda
sha256("acm" + "e42") # 7c187954…3c1fda
Identical. The pair ("acme", "42") and ("acm", "e42") both become acme42, so tenant acm reads tenant acme's cached profile. The same thing happens with first + last ("john" + "[email protected]" == "johns" + "[email protected]") and with any two-variable signature you build by pasting strings together.
The digest is collision-resistant; the encoding is not injective. Fix it at the boundary: join with a separator that cannot appear in the parts, length-prefix each field (3:acme2:42), or hash a canonical serialization. sha256("acme" + "-" + "42") gives d3f5d66e… — a different key, and one that no other pair can produce.
2. The same object, two different hashes
If you hash a JSON payload, key order and whitespace are part of the bytes:
JSON.stringify({a:1,b:2}) // {"a": 1, "b": 2} → d8497d9d…
JSON.stringify({b:2,a:1}) // {"b": 2, "a": 1} → d5b8aed2…
JSON.stringify({a:1,b:2}, null, 0) // {"a":1,"b":2} → 43258cff…
Same data, three digests. Two services "hashing the payload" will disagree forever, and a signature computed in Node will never verify against one computed in Go. Canonicalize before hashing: sort keys recursively and use compact separators (or RFC 8785 JCS). Compare canonical bytes, not objects.
3. A digest is bytes, not a string
-
Hex vs Base64. A 32-byte digest is 64 hex chars, or 44 chars in Base64 (
LXEWQrcmsEQBYnyp+6wy9chTD7GQPMTbAiWHF5IaSIE=). Base64url drops the=→ 43. Strip padding on one side only and every comparison fails. -
Case. Some libraries upper-case hex;
digest()returns lower-case. -
Tool output is not the digest.
openssl dgst -sha256printsSHA2-256(stdin)= 2cf24dba…, whileshasum -a 256prints the bare hex plus a filename. Comparing whole output lines is a guaranteed mismatch.
And when the digest is a MAC, compare it in constant time — hmac.compare_digest or crypto.timingSafeEqual — not with ==.
4. Never sign with hash(secret + message)
SHA-256 is Merkle–Damgård, so anyone holding hash(secret || message) can append their own suffix and recompute a valid digest without the secret (length extension). Use HMAC-SHA256, which is what the signature you're debugging almost certainly expects.
If you need to eyeball a digest while debugging, this SHA-256 tool runs entirely in the browser — paste the exact bytes, pick hex or Base64, and copy the digest without an openssl prefix sneaking into your comparison.
Top comments (0)