Web Crypto vs JavaScript Hash Libraries: What ToolRunner Actually Uses
How ToolRunner's Hash Generator computes SHA digests with crypto.subtle.digest, why MD5 and bcrypt need pure-JS libraries, and the trade-offs involved.
by Dowon Oh
When I built the Hash Generator, I assumed the implementation would be one function call. The browser ships a real cryptography API — crypto.subtle.digest — and for SHA-1, SHA-256, and SHA-512 it genuinely is that simple: hand it bytes, get a digest back, zero bytes added to the bundle. Then I added MD5 to the algorithm list, and then bcrypt, and discovered the part nobody mentions in the "just use Web Crypto" advice: the browser's native API deliberately ships neither. MD5 is too broken to standardize; bcrypt is a password-hashing scheme, not a digest, and lives outside the API's scope entirely. So the tool ended up a hybrid — native code where the platform provides it, two lazy-loaded JavaScript libraries where it does not — and the seams between those two worlds shaped more of the design than I expected. This article walks through what runs where, why, and the trade-offs (bundle size, streaming, secure-context availability) that come with each path.
What the browser gives you for free
The SHA family is the easy half. In src/lib/hashHelpers.ts, every SHA digest goes through the same native call. The only real work before it is normalizing the input: the helper accepts either an ArrayBuffer or a Uint8Array view, and slices the view's exact byte range into a fresh buffer so a subarray with a non-zero byteOffset hashes the bytes it points at, not the entire underlying allocation. Here is the actual shape of the call:
const subtleAlgo =
algo === 'sha-1' ? 'SHA-1' : algo === 'sha-256' ? 'SHA-256' : 'SHA-512';
// Slice the view's exact range into a fresh ArrayBuffer —
// correct even when `bytes` is a subarray view (byteOffset > 0).
const sliced = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
const hashBuf = await crypto.subtle.digest(subtleAlgo, sliced);
Per MDN's SubtleCrypto.digest documentation, the supported algorithms are SHA-1, SHA-256, SHA-384, and SHA-512. ToolRunner surfaces three of the four — I left SHA-384 out of the UI because in years of verifying download checksums and API signatures I have almost never been handed one, and every algorithm in the pill row is another row the multi-algorithm display has to compute and render. Adding it later would be a one-line change to the subtleAlgo mapping.
Text input goes through the same path after a TextEncoder pass. That detail matters more than it looks: hashing the UTF-8 bytes (rather than, say, UTF-16 code units) is what makes CJK and emoji input produce the canonical RFC test vectors. Any tool that gets this wrong will happily emit a digest — just not the one every other implementation on Earth produces.
The appeal of this path is hard to overstate. The digest runs in native code, the implementation is maintained by browser vendors rather than by me, and it costs literally zero bundle bytes. For a site whose whole pitch is "open a URL, get a working tool," that last property is the one I care about most.
The two algorithms the browser refuses to ship
MD5 is not in Web Crypto, and that is a policy decision, not an oversight. The W3C Web Cryptography specification defines the registered algorithms, and MD5 never made the list — its collision resistance has been comprehensively broken since the mid-2000s, and standardizing it in a new API would have been an endorsement. I have some sympathy for that position. But the practical reality is that MD5 is still everywhere as a checksum: mirror sites publish MD5 sums next to downloads, legacy APIs fingerprint payloads with it, and ETags in the wild are frequently MD5 digests. People arrive at a hash tool with an MD5 string in their clipboard whether or not the W3C approves.
Bcrypt is absent for a different reason: it is not a digest algorithm at all. It is a password-hashing scheme — introduced by Provos and Mazières in the 1999 USENIX paper "A Future-Adaptable Password Scheme" — with a built-in salt and a tunable cost factor whose entire purpose is to be slow, so brute-forcing a leaked hash database stays expensive as hardware improves. crypto.subtle.digest is a pure function from bytes to bytes; bcrypt's salt means hashing the same password twice yields different outputs, and verification requires a dedicated compare operation rather than string equality. It simply does not fit the digest interface.
So both algorithms needed JavaScript implementations. The question was how to include them without making every visitor pay for them.
Lazy imports: paying only when you ask
My rule for the helper module was blunt: no top-level imports of either library. Both are pulled in with dynamic import() inside the function body, which Vite's bundler (Rollup underneath) turns into separate chunks that only load when the code path actually runs:
// MD5 — spark-md5, loaded on first MD5 computation
if (algo === 'md5') {
const { default: SparkMD5 } = await import('spark-md5');
const spark = new SparkMD5.ArrayBuffer();
spark.append(md5Input);
const hex = spark.end(); // lowercase hex
// ...
}
// bcrypt — bcryptjs, loaded on first hash or compare
export async function bcryptHash(plaintext: string, cost: number): Promise<string> {
const bcrypt = await import('bcryptjs');
return bcrypt.hash(plaintext, cost);
}
export async function bcryptCompare(plaintext: string, hash: string): Promise<boolean> {
const bcrypt = await import('bcryptjs');
return bcrypt.compare(plaintext, hash);
}
MD5 goes through spark-md5, a small, fast pure-JS implementation with an ArrayBuffer mode that mirrors how the SHA path consumes bytes. Bcrypt goes through bcryptjs, roughly 30 KB that a visitor who only ever computes SHA-256 digests never downloads.
The lazy-loading discipline had one non-obvious consequence for the verification logic shared with the Hash Checker. The verifyHash helper — which compares a computed digest against a pasted expected value — is deliberately synchronous and bcrypt-unaware. Detecting the bcrypt modular-crypt prefix ($2a$, $2b$, $2y$) and routing to bcryptCompare happens in the page layer, before verifyHash is ever called. If I had folded that routing into the helper, its return type would become Promise<boolean> and, worse, every chunk that imports the helper module would drag bcryptjs along with it, defeating the whole point of the dynamic import.
Bcrypt's slowness also leaks into the UI in a way the SHA algorithms never do. The four digest algorithms recompute live on every keystroke (debounced 200 ms, with a race guard so stale results never overwrite fresh ones). Bcrypt gets an explicit Generate button and a cost slider clamped to 4–14, defaulting to 10 — because a hash function designed to be slow makes a terrible on-keystroke computation, and because the cost factor is a decision the user should make consciously, not a constant I bury in a helper.
One digest, three encodings
crypto.subtle.digest resolves to an ArrayBuffer, and spark-md5 hands back a lowercase hex string, so the tool needs its own encoding layer to present a consistent choice of lowercase hex, uppercase hex, and Base64. The hex path walks the bytes with toString(16).padStart(2, '0') — the padStart is load-bearing, because without it any byte ≤ 0x0F silently emits a single character and you get a 63-character "SHA-256" that matches nothing. Uppercase hex is defined as bytesToHex(buf).toUpperCase() so the lowercase path stays the single source of truth. Base64 builds a binary string and feeds it to btoa — the same RFC 4648 output you can inspect interactively in the Base64 Encoder. The one awkward corner is MD5-as-Base64: since spark-md5 only emits hex, the helper parses the hex string back into bytes and re-encodes. Inelegant, but it keeps every algorithm's output flowing through one set of encoders.
Base64 digests are not just a curiosity, either. If you have read my Content-Security-Policy article, you have seen sha256-… hash sources and Subresource Integrity attributes — those are SHA digests in exactly this Base64 form.
Files, the 5 MiB cap, and the streaming gap
For file input, the page reads the entire file with file.arrayBuffer() and fans the buffer out to all four algorithms in parallel — the multi-algorithm display computes MD5, SHA-1, SHA-256, and SHA-512 simultaneously, so a pasted checksum lights up whichever row matches without you having to know which algorithm produced it.
The honest cost of this design is the size cap: the UI rejects files over 5 MiB before reading them. The cap exists because of the API's biggest structural limitation — crypto.subtle.digest is one-shot. There is no update() you can feed chunks into; the entire input must sit in memory as a single buffer. Ironically, this is the one place where the "inferior" JavaScript path is more capable: spark-md5 has a genuine incremental interface — you can append() chunk after chunk and call end() once — which is exactly how you would hash a multi-gigabyte file from a stream. ToolRunner does not currently use it that way (a single append of the whole buffer keeps the MD5 path symmetric with the SHA path), but if I ever lift the cap, MD5 gets streaming almost for free while the SHA algorithms would need a JS or WASM incremental implementation, surrendering the zero-bundle-bytes advantage that justified Web Crypto in the first place. That asymmetry — native speed versus streaming flexibility — is the core trade-off of this whole topic, and the 5 MiB cap is where I currently draw the line between them.
There is one more availability caveat worth knowing if you build with this API: crypto.subtle is only exposed in secure contexts — HTTPS pages, plus localhost for development. On a plain-HTTP page, crypto.subtle is simply undefined and every digest call throws. ToolRunner is served exclusively over HTTPS (with the header hygiene I covered in the security headers guide), so users never hit this, but it is a classic footgun when someone copies working code into an http:// intranet page and watches it die. The pure-JS libraries, notably, do not care — spark-md5 and bcryptjs run anywhere JavaScript runs, which is a third quiet advantage of the library path.
What I would tell you to steal from this design
If you are building anything that hashes in the browser, the pattern that fell out of this tool generalizes well. Use crypto.subtle.digest for every algorithm it supports — it is free, fast, and maintained by people with better cryptographic review processes than you or me. Reach for a JavaScript library only where the platform refuses to go (MD5 for legacy checksums, bcrypt and friends for password hashing), and when you do, load it with a dynamic import() inside the function that needs it so the common path never pays for the uncommon one. Encode UTF-8 explicitly, padStart your hex, and decide up front whether one-shot hashing is acceptable — because if you need streaming, that decision changes which half of this hybrid you can actually use. And keep anything sensitive out of persistence: the Hash Generator never writes plaintext input to storage and never syncs it into the URL, because a hash tool that leaks its inputs has failed at the only job that matters.