MD5 and SHA Hashes in the Browser, and Why I Wrote MD5 by Hand
You download an installer or a Linux image, and the download page lists a long hex string next to it labelled “SHA-256”. Checking it means opening a terminal and remembering whether it’s shasum -a 256 or certutil -hashfile. I wanted a page that does it in the browser, for MD5 and the SHA family at once. The browser already computes SHA hashes. MD5 it doesn’t, so ToolOnTap’s hash generator carries its own MD5 implementation, about 45 lines of TypeScript.
What a hash is for
A hash function turns any amount of data into a short, fixed-size value. The same input always gives the same hash, and changing a single bit changes the result completely. That’s what makes it useful as a fingerprint: compare two 64-character strings instead of two 4 GB files.
The five algorithms on the page differ mainly in length, which also helps you recognize a checksum you’ve been given:
| Algorithm | Bits | Hex characters | Still safe against deliberate forgery? |
|---|---|---|---|
| MD5 | 128 | 32 | No, collisions have been practical since 2004 |
| SHA-1 | 160 | 40 | No, a public collision was shown in 2017 |
| SHA-256 | 256 | 64 | Yes |
| SHA-384 | 384 | 96 | Yes |
| SHA-512 | 512 | 128 | Yes |
SHA comes free with Web Crypto
Browsers have a built-in hashing API, crypto.subtle.digest. The Web Crypto specification gives it four algorithms: SHA-1, SHA-256, SHA-384 and SHA-512. They run in the browser’s native code, so they’re fast, and they need no library at all. The page asks for all four in parallel and adds MD5 next to them:
export async function hashAll(data: Uint8Array): Promise<Record<Algorithm, string>> {
const subtle = globalThis.crypto.subtle
const sha = await Promise.all((['SHA-1', 'SHA-256', 'SHA-384', 'SHA-512'] as const).map(async a => [a, toHex(new Uint8Array(await subtle.digest(a, data as BufferSource)))] as const))
return { MD5: toHex(md5(data)), ...Object.fromEntries(sha) } as Record<Algorithm, string>
}
Two browser rules shape the rest of the tool:
crypto.subtleonly exists on secure pages, HTTPS or localhost. That’s fine for a public site, and worth knowing if you copy this code into a page you serve over plain HTTP on a local network.digesttakes the whole input at once. There’s no streaming version, so a file is read fully into memory before it’s hashed. That’s why the page caps files at 512 MB and suggests your system’s own tools (shasum,certutil) for bigger disk images.
Why MD5 is missing, and writing it anyway
The Web Crypto specification doesn’t include MD5 at all, which makes sense for an API meant for security work. But MD5 is far from gone. Plenty of download pages, package mirrors and storage systems still publish MD5 checksums, so leaving it out would leave the tool unable to check them.
MD5 is defined in RFC 1321, and it’s short enough to write from the description. It works on 64-byte blocks. Each block is read as sixteen 32-bit words in little-endian order and mixed into four 32-bit state values over 64 steps, in four rounds of 16 that each use a different bit function. Each step adds one constant from a table of 64. The RFC prints the table, but it also says where the numbers come from, so the code computes them instead of copying 64 hex values:
const K = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32) >>> 0)
Each constant is the integer part of |sin(i)| × 2³² for i from 1 to 64. A JavaScript number carries 53 bits of precision and only the top 32 are needed, so the computed values match the RFC’s table, and the tests would fail on any single wrong constant.
JavaScript has no 32-bit unsigned integers, so the code ends every addition with >>> 0 to wrap it back into the range 0 to 2³² − 1, and the rotations are written with << and >>>.
The fiddly part is the end. After the last full block, MD5 appends one 0x80 byte, pads with zeros, and finishes with the input’s length in bits as a 64-bit little-endian number. If the leftover bytes plus that marker and length don’t fit in one 64-byte block, padding takes two blocks. The code builds that tail separately, which also means it never copies the whole input: a 500 MB file is processed where it is, block by block.
How I know it’s right
A hash function that’s wrong by one bit still produces a convincing hex string, so the tests had to be strict:
- The RFC 1321 test suite: the seven inputs printed at the end of the RFC, from the empty string (
d41d8cd98f00b204e9800998ecf8427e) to eighty digits. - Every length from 0 to 130 bytes, compared with Node’s own MD5. This crosses the 56-byte and 64-byte boundaries where padding switches from one block to two, which is where hand-written MD5 usually breaks.
- 3,000,001 bytes, to cover large input.
- UTF-8 text with accents, a coffee emoji and Arabic, through all five algorithms, compared with Node.
Checking a download against its checksum
The “Compare with an expected hash” box is the part I use most. You paste the checksum from the download page, and the tool compares it with all five results:
export function findMatch(hashes: Record<Algorithm, string>, expected: string): Algorithm | null {
const want = expected.trim().replace(/\s+/g, '').toLowerCase()
return ALGORITHMS.find(a => hashes[a] === want) ?? null
}
Removing whitespace and ignoring case means output copied from PowerShell’s Get-FileHash (uppercase) or from certutil (spaced on some Windows versions) matches without editing. And because it checks every algorithm, you don’t need to know which one the site used. If nothing matches, the file differs or the checksum is for a different file.
A checksum is only as trustworthy as the place you got it. It reliably catches a cut-off download or a corrupted copy. Against someone who replaced the file on purpose, it only helps if the checksum came from a source they couldn’t change, such as the project’s official HTTPS site, and only with SHA-256 or stronger.
For text, the tool hashes the exact UTF-8 bytes you typed, 120 ms after you stop typing. A trailing space or Windows line endings (CR LF instead of LF) give a different hash, which is the usual reason a hash of “the same text” doesn’t match.
What hashes aren’t for
Two things I’d tell anyone using this page at work:
- Don’t store passwords as MD5 or SHA hashes, even SHA-512. These functions are designed to be fast, which helps an attacker try billions of guesses. Use a password hashing function built to be slow, such as Argon2, bcrypt or scrypt.
- Don’t use MD5 or SHA-1 where someone might forge a match, such as signatures or deduplication of files from untrusted users. For accidental damage, both still work fine.
Try it
Open the hash generator, choose “Text” or “File”, and all five hashes appear together. Paste a published checksum into the compare box to see which one matches. Other tools for developers are on the developer tools page.