How to check sha256 of a file
Hash the file before it leaves and hash the copy that comes back. sha256sum export.csv prints the digest; run it again on the downloaded file. Identical digests mean identical bytes. A different digest with a different size points at truncation, and a difference of one byte per line points at newline translation.
Why check this
An upload has two ends and a tester usually watches one of them. The server answers 201 and the file appears in a list, which says the request was accepted, not that the stored bytes match the bytes on disk.
Run it after a change to upload or download code, after a storage backend swap, and after a proxy goes in front of the endpoint. The failure it prevents is concrete: a 593 byte CSV export that arrives 614 bytes long, one carriage return per line, which a downstream importer reads as a trailing field on every row.
The check says nothing about what the file is, or whether the server should have accepted it. Those are How to check file type and How to check if file type is validated on the server.
Prerequisites
sha256sumfrom GNU coreutils 8.32, which ships with Git Bash. Without it, thecertutilof step 2 is on every Windows install.- Node 22 for the target below and for step 3. See the node:crypto hash docs.
- curl 8.0 or later, for the
-T,--crlfand-rof steps 4, 6 and 7. See the curl manual. - A target that stores what it receives and reports the digest of the stored bytes. Save it as
store-server.mjsand run it withnode.
import { createServer } from 'node:http';
import { createHash } from 'node:crypto';
const store = new Map();
createServer((req, res) => {
const name = req.url.split('/').pop();
if (req.method === 'PUT' || req.method === 'POST') {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = Buffer.concat(chunks);
store.set(name, body);
res.writeHead(201, { 'content-type': 'application/json' });
res.end(JSON.stringify({
name, size: body.length,
sha256: createHash('sha256').update(body).digest('hex'),
}) + '\n');
});
return;
}
const body = store.get(name);
if (!body) { res.writeHead(404).end(); return; }
// /norange/ answers 200 to a Range request, the way a server without range support does.
const r = req.url.startsWith('/norange/') ? '' : req.headers.range || '';
const m = /^bytes=(\d+)-(\d*)$/.exec(r);
if (m) {
const start = Number(m[1]);
const end = m[2] ? Number(m[2]) : body.length - 1;
res.writeHead(206, {
'content-range': `bytes ${start}-${end}/${body.length}`,
'content-length': end - start + 1,
'accept-ranges': 'bytes',
});
res.end(body.subarray(start, end + 1));
return;
}
res.writeHead(200, { 'content-length': body.length, 'accept-ranges': 'bytes' });
res.end(body);
}).listen(8932, '127.0.0.1', () => console.log('integrity target on 8932'));
- A payload with line breaks, because that is where transfer damage shows. The run below used a 593 byte CSV of 21 LF-terminated lines.
{ echo "id,email,amount"; for i in $(seq 1 20); do echo "$i,user$i@example.test,$((i*17)).00"; done; } > export.csv
Steps
- Step 1.
Take the digest of the local file before anything is sent.
sha256sum export.csvf536f366ce009f175a5394298bde89243d1b3ff71e8245507d9cee47dac796cd *export.csvThe asterisk is the mode marker, not part of the name: this build reads in binary mode by default.
- Step 2.
Take the same digest with the tool that is on every Windows machine.
certutil -hashfile export.csv SHA256SHA256 hash of export.csv: f536f366ce009f175a5394298bde89243d1b3ff71e8245507d9cee47dac796cd CertUtil: -hashfile command completed successfully.Three lines instead of one, the name on a label, and no mode marker. The digest is identical, so compare the hex alone.
- Step 3.
Take it a third time from Node, the form that fits a test runner.
node -e "const{createHash}=require('node:crypto');const{readFileSync}=require('node:fs');console.log(createHash('sha256').update(readFileSync(process.argv[1])).digest('hex'))" export.csvf536f366ce009f175a5394298bde89243d1b3ff71e8245507d9cee47dac796cdBare hex, no file name and no marker. Three tools, three output shapes, one digest.
- Step 4.
Upload the file and read the digest the server computed over what arrived.
curl -s -T export.csv http://127.0.0.1:8932/upload/export.csv{"name":"export.csv","size":593,"sha256":"f536f366ce009f175a5394298bde89243d1b3ff71e8245507d9cee47dac796cd"}The digest matches step 1, so the request body was the file.
- Step 5.
Hash the response body on the way back, without saving it first.
curl -s http://127.0.0.1:8932/file/export.csv | sha256sumf536f366ce009f175a5394298bde89243d1b3ff71e8245507d9cee47dac796cd *-The name reads as a single dash because the bytes came from standard input. Steps 1, 4 and 5 agreeing closes the round trip.
- Step 6.
Send the same file with newline translation switched on, as a text mode transfer does.
curl -s --crlf -T export.csv http://127.0.0.1:8932/upload/export-text.csv{"name":"export-text.csv","size":614,"sha256":"3a330fc9f54d361c52534c851904bac4017feb81d6b46655ef725c7b51877e6b"}Same file on disk, 21 bytes more on the server, one per line, and a digest with nothing in common with step 1. A hash reports damage; the size measures it.
- Step 7.
Fetch part of the file, the way a transfer that stopped early leaves it.
curl -s -r 0-399 -o partial.csv -w '%{http_code} %{size_download} bytes\n' http://127.0.0.1:8932/file/export.csv206 400 bytesThe 206 and the byte count are the signal. The file left on disk carries neither.
- Step 8.
Hash the fragment.
sha256sum partial.csv972a914c19c5ed9bdea34501c146878d0a31cd03d8e177290ab1f0c0a84745e4 *partial.csvA well formed 64 character digest over 400 bytes. Nothing in it says the file is short. Only the comparison with step 1 does.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Steps 1, 4 and 5 print the same digest | The round trip preserved every byte | Record the digest as the expected value for this fixture and assert on it. |
| Server digest differs, sizes are equal | The content changed without changing length | Compare the two files byte by byte. Re-encoding and an added byte order mark are the usual causes. |
| Server digest differs, size larger by the line count | A text mode transfer rewrote the line endings | Send in binary mode. In curl that means dropping --crlf; in an FTP or SFTP client it is the transfer type setting. |
| Server digest differs, size smaller | The transfer ended early | Read the byte count, not the file. Continue at How to test an interrupted file upload. |
| Digests agree and the file still opens wrong | The bytes are intact and the reader is not | Stop testing transfer and start testing the parser, the encoding and the declared type. |
Common mistakes
What to check next
- How to test an interrupted file upload: what the server keeps when a transfer stops part way.
- How to check file type: a digest proves the bytes travelled, not that they are the expected format.
- How to check if file type is validated on the server: whether the endpoint read those bytes before storing them.
- How to check data integrity after migration: the same comparison over rows rather than bytes.
- How to check if a build is reproducible: the same digest technique over two artefacts that should match.
FAQ
How do I check a file checksum on Windows without Git Bash?
certutil -hashfile <file> SHA256 runs on every Windows install and needs no download. It prints three lines and puts the file name on a label, so a script comparing it with sha256sum output has to extract the hex line.
How do I verify file integrity after a download?
Take the digest of the source before it is served, publish it, and run sha256sum -c against it after the download. On a manifest naming the downloaded copy, a match printed roundtrip.csv: OK and exit 0, and a truncated copy printed partial.csv: FAILED and exit 1.
Do sha256sum and certutil produce the same hash?
Yes, for the same bytes. Steps 1 and 2 printed the same 64 hex characters for export.csv. The output formats differ and the letter case can differ between tools and versions, so compare lowercased hex rather than whole lines.
Is MD5 enough for a transfer check?
For catching an accidental truncation or a rewritten newline, yes. For a file another party supplied, no: MD5 collisions are constructible, so a matching MD5 does not establish that nobody swapped the file.
Verified
Verified by Maks Vernycurl 8.21.0sha256sum (GNU coreutils) 8.32certutil 10.0.22621.1node 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
basic7 minpublished updated Maks Verny