DEV Community

Cover image for Building a Hash Generator with Web Crypto API and a Pure-JS MD5 Fallback
Shaishav Patel
Shaishav Patel

Posted on

Building a Hash Generator with Web Crypto API and a Pure-JS MD5 Fallback

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("");
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. It's async. digest() returns a Promise. For large files, this prevents UI freezing since the browser can offload computation.

  2. Algorithm names match directly. Web Crypto uses "SHA-256", "SHA-512", etc. — same strings we display in the UI.

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

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

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

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

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

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

This works because:

  1. Web Crypto's digest() is fast enough for small inputs (sub-millisecond)
  2. The async pattern prevents blocking even if input gets large
  3. 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")
Enter fullscreen mode Exit fullscreen mode

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

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:

  1. Use crypto.subtle.digest() for SHA — it's native, fast, and async
  2. Implement MD5 yourself if you need it — 50 lines, no dependency
  3. Normalize all input to Uint8Array before hashing
  4. Always padStart(2, "0") when converting bytes to hex
  5. Use ssr: false in 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)