Crypto & Hash Testing
Known-answer (hardcoded-digest) testing for hash, HMAC, and signature functions, plus the cross-input-type equivalence check (string vs ArrayBuffer) that catches encoding bugs a "deterministic" test misses.
The "Same Input, Same Output" Trap
The most natural test to write for a hash function is a consistency check: hash the same input twice and assert the two results match.
import { describe, it, expect } from "vitest";
import { hashContent } from "../hash.js";
describe("hashContent", () => {
it("produces consistent output for the same input", async () => {
const hash1 = await hashContent("hello world");
const hash2 = await hashContent("hello world");
expect(hash1).toBe(hash2);
});
});This test passes. It also proves almost nothing.
A hash function that encodes the string as UTF-16 instead of UTF-8, hashes the wrong byte range, or applies the wrong algorithm entirely will still return the same output for the same input every time. Determinism is a property of nearly any pure function -- it does not tell you the bytes flowing into the digest are the ones you intended.
Warning
A consistency test (hash(x) === hash(x)) and a uniqueness test (hash(x) !== hash(y)) both pass even when the encoding is wrong. They check that your function is deterministic, not that it is correct. Encoding bugs sail straight through.
Known-Answer Testing: Pin to an External Digest
The fix is to assert the output against a literal digest computed by an independent tool -- one that does not share your code's encoding logic. For SHA-256 of "hello world", the answer is fixed by the algorithm and reproducible anywhere:
it("matches the known SHA-256 digest of 'hello world'", async () => {
const hash = await hashContent("hello world");
// Known external digest -- verify with: echo -n "hello world" | sha256sum
expect(hash).toBe(
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9",
);
});You can regenerate that constant from a shell, with no involvement from your application code:
echo -n "hello world" | sha256sum
# b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9Tip
The -n flag on echo matters: without it, echo appends a trailing newline and you hash "hello world\n", producing a completely different digest. This is the same class of byte-level mistake the test is meant to catch -- so get the reference value right.
Because the expected value comes from outside your codebase, a UTF-16 bug or an off-by-one byte slice now fails the test instead of silently agreeing with itself. This is the difference between testing that the function is consistent and testing that it is correct.
Cross-Input-Type Equivalence
Hash functions often accept more than one input type -- a string, an ArrayBuffer, a Buffer. Conceptually, the same bytes should produce the same digest regardless of how they arrive. In practice, each input path has its own encoding step, and they can drift apart.
Assert that a string and its byte-buffer equivalent hash identically:
it("hashes a string and its ArrayBuffer identically", async () => {
const text = "hello world";
const arrayBuffer = new TextEncoder().encode(text).buffer;
const hashFromString = await hashContent(text);
const hashFromBuffer = await hashContent(arrayBuffer);
expect(hashFromBuffer).toBe(hashFromString);
});If these two paths diverge, the same logical content produces two different hashes -- a bug that breaks deduplication, cache keys, and content-addressed storage. A pure known-answer test on one input type would not catch it; you need the equivalence assertion across types.
Info
This is the second leg of the discipline. A single known-answer vector proves the encoding is correct for one input shape. Cross-input-type equivalence proves every accepted input shape funnels into that same correct encoding.
The Transferable Rule
For any hash, HMAC, or encoding function, write two kinds of assertions:
At least one known-answer vector -- output pinned to a constant computed by an independent tool (
sha256sum,openssl, a spec test vector, another language's stdlib).Cross-input-type equivalence -- every accepted input type (
string,ArrayBuffer,Buffer) hashing to the same digest.
The first catches encoding and algorithm bugs. The second catches input-path drift. Together they pin the function's behavior to an external ground truth, not just to itself.
JWT Signatures: Known-Answer Applies Here Too
A JWT is signed data, so the same discipline transfers directly to authentication code -- from the signature-vector angle.
HMAC-SHA256 (the HS256 algorithm) is deterministic: a fixed secret plus a fixed payload yields exactly one signature. That means you can treat a JWT signature like any other known-answer vector -- pin the produced token's signature segment against a value generated by an independent tool, and a regression in your signing path will fail loudly.
A round-trip test that only calls createToken and feeds the result straight into verifyToken is not a known-answer test -- it is the same "same input, same output" trap from the top of this page, wearing a JWT costume. Both functions come from the same library, so a shared bug (wrong signing algorithm, wrong header encoding) agrees with itself and the test still passes. The fix is the same one used for hashes above: pin the signature to a value computed outside ..
import { describe, it, expect } from "vitest";
import { createToken } from "../utils/jwt.js";
const TEST_SECRET = "test-secret-key-that-is-long-enough-for-hs256";
describe("JWT signing -- known-answer vector", () => {
it("matches an independently-computed HS256 signature", async () => {
// { noTimestamp: true } mirrors the `jsonwebtoken` npm package's own sign()
// option -- it skips the auto-added `iat` claim so the payload is exactly
// {"sub":"user-123"} and the token is byte-for-byte reproducible.
const token = await createToken({ sub: "user-123" }, TEST_SECRET, {
noTimestamp: true,
});
const [, , signature] = token.split(".");
// Vector computed with Node's own `crypto` module, independent of
// ../utils/jwt.js, for header {"alg":"HS256","typ":"JWT"} and payload
// {"sub":"user-123"}:
// node -e 'const c=require("crypto");
// const b64url=b=>b.toString("base64").replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"");
// const h=b64url(Buffer.from(JSON.stringify({alg:"HS256",typ:"JWT"})));
// const p=b64url(Buffer.from(JSON.stringify({sub:"user-123"})));
// console.log(b64url(c.createHmac("sha256","test-secret-key-that-is-long-enough-for-hs256").update(h+"."+p).digest()))'
expect(signature).toBe("6D3L8TEGC89wuRUpJ9f976l53ULJfJ4guB4DExb4Xrs");
});
});That constant came from node:crypto's HMAC implementation, not from . -- the same independence the SHA-256 vector above has from hashContent. An encoding change, a header-field reordering, or a switch to the wrong algorithm now fails this test instead of agreeing with itself.
Tip
You can regenerate this vector from any independent HMAC-SHA256 implementation -- jwt.io's debugger, the jose CLI, or another language's standard library -- as long as it never imports the code under test.
Once the signature itself is pinned, a round-trip and a negative case are useful supplements -- they exercise verifyToken, which the pinned vector above does not touch:
import { describe, it, expect } from "vitest";
import { createToken, verifyToken } from "../utils/jwt.js";
describe("JWT signing -- round-trip supplements", () => {
it("verifies a token signed with the same secret", async () => {
const token = await createToken({ sub: "user-123" }, TEST_SECRET, "1h");
const payload = await verifyToken(token, TEST_SECRET);
expect(payload.sub).toBe("user-123");
});
it("rejects a token signed with a different secret", async () => {
const token = await createToken({ sub: "user-123" }, TEST_SECRET, "1h");
await expect(
verifyToken(token, "a-completely-different-secret-key"),
).rejects.toThrow();
});
});The round-trip confirms verifyToken accepts what createToken signs; the negative case confirms a wrong secret is rejected rather than quietly accepted. Neither replaces the pinned vector -- both use only this codebase's own functions, so a shared signing bug would pass them exactly as easily as the "same input, same output" hash test at the top of this page.
Note
This page covers JWTs only from the known-answer / signature-vector angle -- the part that shares its discipline with hash testing. Time-based behavior such as token expiry (tested with vi.setSystemTime) is a clock-control concern and lives on the Backend & Node.js Testing page.
Key Principles
Never rely on consistency alone.
hash(x) === hash(x)passes with a broken encoding. It is necessary, never sufficient.Pin to an external constant. The expected digest must come from a tool that does not share your code's encoding logic, so a bug cannot agree with itself.
Mind the reference bytes.
echo -nvsecho, UTF-8 vs UTF-16, trailing newlines -- the reference value is only useful if you generated it from the exact bytes you mean to hash.Assert cross-input-type equivalence. Every accepted input shape (
string,ArrayBuffer,Buffer) must produce the same digest.Treat signatures as vectors too. Deterministic HMAC signatures (JWT
HS256) get the same known-answer treatment: pin the signature to a value computed by an independent tool, then supplement with a same-library round-trip and a wrong-secret rejection.