Hash files in the browser with Web Crypto
To hash a file in the browser, read its bytes with file.arrayBuffer(), pass them to
crypto.subtle.digest(), and convert the result to hexadecimal. The page below lets you select a
local file, calculate its checksum, and optionally compare it with one supplied by the publisher.
It reads the file locally without uploading it.
Browser compatibility and requirements
You need a browser with crypto.subtle and File.prototype.arrayBuffer. Web Crypto requires a
secure context: use HTTPS when
hosting the page, or a loopback address such as http://127.0.0.1 for local development. The example
checks both APIs before enabling the form.
Save the two files below in the same directory. No JavaScript packages or build step are needed. The local serving command uses a POSIX shell and Python 3; you can also use an existing static file server.
Supported hash algorithms
Use the same algorithm as the publisher’s checksum. This example defaults to SHA-256 and also offers SHA-384 and SHA-512. Their hexadecimal digests contain 64, 96, and 128 characters, respectively. The Web Crypto digest API also supports SHA-1, but it is unsuitable for new security-sensitive checks because of known collision weaknesses.
Implementing file hashing
Save this as index.html. The deferred script runs after the form has been parsed. The controls
start disabled so the page cannot submit if JavaScript fails to load.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>File checksum</title>
<script src="hash.js" defer></script>
</head>
<body>
<h1>File checksum</h1>
<form id="hashForm">
<fieldset id="hashControls" disabled>
<legend>Hash a local file</legend>
<p>
<label for="algorithmSelect">Hash algorithm</label>
<select id="algorithmSelect">
<option value="SHA-256">SHA-256</option>
<option value="SHA-384">SHA-384</option>
<option value="SHA-512">SHA-512</option>
</select>
</p>
<p>
<label for="fileInput">File (up to 50 MiB)</label>
<input type="file" id="fileInput" required />
</p>
<p>
<label for="expectedHash">Expected hexadecimal checksum (optional)</label>
<input type="text" id="expectedHash" spellcheck="false" autocomplete="off" />
</p>
<button type="submit">Calculate checksum</button>
</fieldset>
</form>
<p id="hashOutput" role="status">Checking browser support…</p>
<noscript>Enable JavaScript to calculate a checksum.</noscript>
</body>
</html>
Save this as hash.js:
async function calculateHash(file, algorithm = 'SHA-256') {
if (!(file instanceof File)) {
throw new Error('Input must be a File object')
}
const arrayBuffer = await file.arrayBuffer()
const hashBuffer = await crypto.subtle.digest(algorithm, arrayBuffer)
return Array.from(new Uint8Array(hashBuffer), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('')
}
function setupFileHashing() {
const form = document.getElementById('hashForm')
const controls = document.getElementById('hashControls')
const fileInput = document.getElementById('fileInput')
const algorithmSelect = document.getElementById('algorithmSelect')
const expectedInput = document.getElementById('expectedHash')
const output = document.getElementById('hashOutput')
const digestLengths = { 'SHA-256': 64, 'SHA-384': 96, 'SHA-512': 128 }
if (
!window.isSecureContext ||
!window.crypto?.subtle ||
typeof File === 'undefined' ||
typeof File.prototype.arrayBuffer !== 'function'
) {
output.textContent =
'Use HTTPS or localhost in a browser with Web Crypto and File.arrayBuffer support.'
return
}
controls.disabled = false
output.textContent = 'Choose a file, then calculate its checksum.'
form.addEventListener('input', () => {
output.textContent = 'Inputs changed. Calculate the checksum again.'
})
form.addEventListener('submit', async (event) => {
event.preventDefault()
if (controls.disabled) return
const file = fileInput.files[0]
if (!file) {
output.textContent = 'Choose a file first.'
return
}
if (file.size > 50 * 1024 * 1024) {
output.textContent = 'Choose a file of 50 MiB or smaller.'
return
}
const algorithm = algorithmSelect.value
const expected = expectedInput.value.trim().toLowerCase()
if (
expected !== '' &&
(expected.length !== digestLengths[algorithm] || !/^[0-9a-f]+$/.test(expected))
) {
output.textContent = `Enter ${digestLengths[algorithm]} hexadecimal characters for ${algorithm}.`
return
}
controls.disabled = true
output.textContent = 'Computing checksum…'
try {
const hash = await calculateHash(file, algorithm)
let verdict = 'No expected checksum supplied.'
if (expected !== '') {
verdict = hash === expected ? 'Checksums match.' : 'Checksums do not match.'
}
output.textContent = `${file.name} (${algorithm}): ${hash}. ${verdict}`
} catch (error) {
output.textContent =
error instanceof DOMException && error.name === 'NotReadableError'
? 'Could not read the file. Select it again and retry.'
: 'Could not calculate the checksum. Try a smaller file or another browser.'
} finally {
controls.disabled = false
}
})
}
setupFileHashing()
calculateHash() reads the raw bytes without decoding them as text. The filename and MIME type
do not enter the digest. It leaves failures for the caller to handle. A file-read error must not
be reported as an unsupported algorithm: the File API can reject a read
when the underlying file changes or becomes inaccessible.
The form locks its inputs while hashing, then enables them again on success or failure. Changing
an input clears the previous result. This keeps a checksum from appearing to belong to a different
file or algorithm. The busy message has no percentage because digest() supplies no progress events.
Verifying file integrity
In a terminal, switch to the directory containing index.html and hash.js, then run:
printf 'abc' > sample.txt
python3 -m http.server 8000 --bind 127.0.0.1
Open http://127.0.0.1:8000/, choose sample.txt, keep SHA-256 selected, and paste this expected
checksum:
ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
Click Calculate checksum. The result includes the filename, algorithm, calculated digest, and Checksums match. The command creates exactly three bytes with no trailing newline. A text editor that adds a newline produces a different checksum.
Replace the first checksum character with 0 and calculate again to see Checksums do not
match. Uppercase hex and surrounding whitespace are accepted; a wrong length, non-hexadecimal
character, or pasted filename is rejected. Leave the field empty to calculate a digest without
claiming that it has been verified. Stop the server with Ctrl+C when finished.
For a real download, obtain the expected checksum from a source you trust, such as the publisher’s authenticated release page. A match checks the file against that value; it does not establish who published it. An attacker able to replace both the file and its checksum can make the comparison succeed. Likewise, run the checker from a page you trust, since its JavaScript controls the result. The Web Crypto security considerations explain why hostile scripts undermine cryptographic operations.
Handling large files
crypto.subtle.digest() does not accept streaming input.
The complete file is read into memory before hashing, and the
digest operation copies its input,
so budget for more memory than the file’s size. This page rejects files over 50 MiB before reading
them. That is an example application limit, not a browser limit or a guarantee that every device
has enough memory.
Hashing chunks separately and concatenating their digests does not produce the whole-file digest. Keeping only the last chunk’s digest checks only that chunk. For larger inputs, choose an incremental hashing implementation or hash a stream on the server. Moving this example into a Web Worker does not remove its whole-file memory requirement.
Interpret a mismatch
Check that you selected the intended file, copied only the hexadecimal digest, and chose the publisher’s algorithm. A mismatch can mean a truncated download, a different release, or changed bytes; the checksum alone cannot tell you which. Hash the original downloaded file, since opening and saving it in another application may change its contents.
