DEV Community

zhihu wu
zhihu wu

Posted on

Your Cache Key Has a Collision You Designed Yourself

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)
Enter fullscreen mode Exit fullscreen mode

Now try two unrelated records:

sha256("acme" + "42")    # 7c187954…3c1fda
sha256("acm"  + "e42")   # 7c187954…3c1fda
Enter fullscreen mode Exit fullscreen mode

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…
Enter fullscreen mode Exit fullscreen mode

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 -sha256 prints SHA2-256(stdin)= 2cf24dba…, while shasum -a 256 prints 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)