Every developer runs into hashing at some point. Checking file integrity, storing password fingerprints, verifying API payloads, generating cache keys.
Most reach for a CLI tool (shasum, md5sum) or an npm package. But what if you could hash text and files directly in the browser — no installs, no dependencies, no server?
I built a Hash Generator that supports MD5, SHA-1, SHA-256, SHA-384, and SHA-512. All client-side. Here's how it works under the hood.
The Web Crypto API Does Most of the Work
Modern browsers ship with crypto.subtle — a native cryptographic API that handles SHA algorithms with hardware-accelerated performance.
async function computeHash(
data: Uint8Array,
algorithm: Algorithm
): Promise<string> {
if (algorithm === "MD5") return md5(data);
const hashBuffer = await crypto.subtle.digest(algorithm, data);
return Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, "0"))
.join("");
}
That's it for SHA-1, SHA-256, SHA-384, and SHA-512. crypto.subtle.digest() accepts the algorithm name and a BufferSource, returns an ArrayBuffer. Convert each byte to a two-character hex string, join them, done.
Three things worth noting:
It's async.
digest()returns a Promise. For large files, this prevents UI freezing since the browser can offload computation.Algorithm names match directly. Web Crypto uses
"SHA-256","SHA-512", etc. — same strings we display in the UI.It's fast. Native implementations are significantly faster than any JavaScript library. SHA-256 on a 10MB file takes milliseconds.
Why MD5 Needs a Pure-JS Implementation
Here's the catch: Web Crypto doesn't support MD5.
And it's intentional. MD5 is cryptographically broken — collisions can be generated in seconds. The Web Crypto spec deliberately excludes it to discourage use in security contexts.
But MD5 is still useful. Checksums, legacy system compatibility, non-security fingerprinting. Developers need it.
So I wrote a ~50-line MD5 implementation directly in the component. No external library.
The MD5 Algorithm in JavaScript
MD5 processes data in 64-byte (512-bit) blocks. Each block goes through 64 rounds of bitwise operations across four auxiliary functions.
Step 1: Padding
The input must be padded to a multiple of 64 bytes, with the original bit length appended:
const len = input.length;
const bitLen = len * 8;
const padLen = ((56 - (len + 1) % 64) + 64) % 64;
const padded = new Uint8Array(len + 1 + padLen + 8);
padded.set(input);
padded[len] = 0x80; // Append 1-bit separator
// Little-endian 64-bit length
const view = new DataView(padded.buffer);
view.setUint32(padded.length - 8, bitLen >>> 0, true);
view.setUint32(padded.length - 4, Math.floor(bitLen / 0x100000000), true);
The 0x80 byte marks the end of the message. Then zeros fill until 56 bytes mod 64. Finally, the original message length (in bits) is written as a little-endian 64-bit integer.
Step 2: Block processing
Four state variables (a, b, c, d) are initialized to fixed values. Each 64-byte block modifies these through 64 rounds:
- Rounds 0–15:
F(b, c, d) = (b & c) | (~b & d) - Rounds 16–31:
G(b, c, d) = (b & d) | (c & ~d) - Rounds 32–47:
H(b, c, d) = b ^ c ^ d - Rounds 48–63:
I(b, c, d) = c ^ (b | ~d)
Each round adds a constant from a precomputed table (k), adds a word from the block, applies a left rotation by a specified amount (s), and chains into the next round.
Step 3: Output
The final a, b, c, d values are concatenated as little-endian bytes and converted to hex:
[a, b, c, d].map(n =>
[n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]
.map(b => b.toString(16).padStart(2, "0"))
.join("")
).join("");
Note the little-endian extraction — MD5 uses little-endian byte order, unlike SHA which uses big-endian. Getting this wrong produces valid-looking but incorrect hashes.
Why Not Use a Library?
An npm MD5 package would work fine. But it adds a dependency for ~50 lines of deterministic code. The algorithm hasn't changed since 1992. There's no bug fix or security patch coming.
Inlining it:
- Eliminates the dependency
- Reduces bundle size (~3-5KB saved)
- Keeps everything in one file for easier maintenance
For a utility like this, fewer dependencies is better.
Normalizing Input: Text vs. Files
A hash function operates on bytes, not strings. So both text and file inputs need to become Uint8Array before hashing.
Text input:
const data = new TextEncoder().encode(value);
TextEncoder converts a JavaScript string to UTF-8 bytes. This matters because the same string can produce different hashes under different encodings. UTF-8 is the standard.
File input:
const buffer = await file.arrayBuffer();
const data = new Uint8Array(buffer);
File.arrayBuffer() reads the raw binary content. No encoding conversion — the bytes go directly to the hash function. This is critical for binary files (images, executables, archives).
By normalizing both paths to Uint8Array, the computeHash function doesn't need to know where the data came from:
const generate = async (
data: Uint8Array,
algo: Algorithm,
upper: boolean
) => {
setIsProcessing(true);
const hash = await computeHash(data, algo);
setOutput(upper ? hash.toUpperCase() : hash);
setIsProcessing(false);
};
Real-Time Hashing on Every Keystroke
Instead of a "Generate" button, the hash updates live as you type:
const handleTextChange = async (value: string) => {
setInput(value);
if (!value) { setOutput(""); return; }
const data = new TextEncoder().encode(value);
await generate(data, algorithm, uppercase);
};
This works because:
- Web Crypto's
digest()is fast enough for small inputs (sub-millisecond) - The
asyncpattern prevents blocking even if input gets large - React's state batching prevents unnecessary re-renders
Changing the algorithm also triggers recomputation with the existing input — so you can compare SHA-256 vs SHA-512 of the same text instantly.
Output Formatting
Two small but important details:
Hex padding:
b.toString(16).padStart(2, "0")
Without .padStart(2, "0"), a byte value of 0x0A would output "a" instead of "0a". This would produce hashes with inconsistent lengths — SHA-256 might be 63 characters instead of 64. Always pad.
Uppercase toggle:
Some tools output uppercase hex (A3B2C1), others lowercase (a3b2c1). Both are valid. The tool defaults to lowercase (more common) but offers a toggle. The hash is recomputed on toggle to maintain consistency.
SSR Pitfall: Web Crypto Is Browser-Only
crypto.subtle doesn't exist on the server. In Next.js with SSR, this causes a build-time crash.
The fix:
const HashGenerator = dynamic(
() => import("./HashGenerator").then((mod) => mod.HashGenerator),
{ ssr: false }
);
ssr: false ensures the component only renders in the browser where crypto.subtle is available. The page shell (SEO content, schema markup) still renders server-side.
Algorithm Comparison: When to Use What
| Algorithm | Output | Speed | Use Case |
|---|---|---|---|
| MD5 | 32 chars | Fastest (JS) | Checksums, cache keys, legacy |
| SHA-1 | 40 chars | Fast | Git commits (legacy), non-security |
| SHA-256 | 64 chars | Fast | File integrity, API signatures |
| SHA-384 | 96 chars | Fast | TLS, certificate chains |
| SHA-512 | 128 chars | Fast | Maximum security, password hashing input |
For anything security-related, use SHA-256 or higher. MD5 and SHA-1 are fine for non-security purposes like deduplication or cache busting.
Try It
The live tool is at ultimatetools.io/tools/coding-tools/hash-generator/.
Paste text or drop a file. Switch between algorithms. Compare outputs. All processing stays in your browser.
If you're building something similar, the key takeaways:
- Use
crypto.subtle.digest()for SHA — it's native, fast, and async - Implement MD5 yourself if you need it — 50 lines, no dependency
- Normalize all input to
Uint8Arraybefore hashing - Always
padStart(2, "0")when converting bytes to hex - Use
ssr: falsein Next.js — Web Crypto is browser-only
Built with Next.js, TypeScript, and zero external crypto libraries. Part of Ultimate Tools — a collection of free, privacy-first browser tools.
Top comments (0)