When an Android APK comes from outside an app store, one of the first useful questions is simple: is this the exact file I expected to receive?
A SHA-256 checksum can answer that identity question. It cannot tell you whether an app is safe, whether its runtime behavior is trustworthy, or whether a modification works as described. Those require separate evidence.
This tutorial builds a small APK checksum verifier that runs entirely in the browser. The selected file stays on the device, JavaScript reads its bytes locally, and the Web Crypto API calculates the digest.
What the verifier will do
The finished tool will:
- accept an APK file;
- calculate its SHA-256 digest locally;
- normalize an optional expected digest;
- report whether the two values match; and
- avoid presenting a checksum match as a security verdict.
No package is required. Modern browsers already expose the cryptographic primitive through crypto.subtle.digest().
Create the interface
Start with a file input, an optional expected-hash field, a button, and a result area.
<main class="verifier">
<h1>APK SHA-256 Verifier</h1>
<p>Select an APK to calculate its SHA-256 checksum locally.</p>
<label for="apk-file">APK file</label>
<input id="apk-file" type="file" accept=".apk,application/vnd.android.package-archive">
<label for="expected-hash">Expected SHA-256 (optional)</label>
<input id="expected-hash" type="text" placeholder="64 hexadecimal characters">
<button id="verify-button" type="button">Calculate checksum</button>
<output id="status" aria-live="polite"></output>
<pre id="digest"></pre>
</main>
aria-live="polite" lets assistive technology announce status changes without interrupting the user. The accept attribute helps the file picker prioritize APKs, but it is only a hint. JavaScript should still validate the selection.
Read the APK bytes
The browser can expose the selected file as an ArrayBuffer:
async function readFileBytes(file) {
return file.arrayBuffer();
}
An ArrayBuffer represents the raw byte sequence. A checksum must be calculated from those exact bytes, not from the displayed file name or metadata.
For a local-only verifier, do not create a fetch() request or append the file to FormData. Neither is needed for hashing.
Calculate SHA-256 with Web Crypto
Pass the bytes to Web Crypto, then convert the returned digest to hexadecimal:
async function sha256Hex(arrayBuffer) {
const digestBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer);
const digestBytes = new Uint8Array(digestBuffer);
return Array.from(digestBytes, (byte) =>
byte.toString(16).padStart(2, "0")
).join("");
}
SHA-256 produces 256 bits. Written as hexadecimal, the result contains 64 characters. Web Crypto is available in secure contexts, so serve the production page over HTTPS.
Normalize the expected checksum
Publishers sometimes display hashes with spaces, line breaks, or uppercase letters. Normalize formatting before comparison:
function normalizeSha256(value) {
return value.toLowerCase().replace(/[^0-9a-f]/g, "");
}
function isValidSha256(value) {
return /^[0-9a-f]{64}$/.test(value);
}
Do not silently accept a reference value of the wrong length. A malformed expected hash cannot support a match or mismatch conclusion.
Connect the workflow
const fileInput = document.querySelector("#apk-file");
const expectedInput = document.querySelector("#expected-hash");
const verifyButton = document.querySelector("#verify-button");
const statusOutput = document.querySelector("#status");
const digestOutput = document.querySelector("#digest");
verifyButton.addEventListener("click", async () => {
const file = fileInput.files[0];
const expected = normalizeSha256(expectedInput.value);
if (!file) {
statusOutput.textContent = "Choose an APK file first.";
return;
}
if (!file.name.toLowerCase().endsWith(".apk")) {
statusOutput.textContent = "The selected file does not have an .apk extension.";
return;
}
if (expected && !isValidSha256(expected)) {
statusOutput.textContent = "Expected SHA-256 must contain 64 hexadecimal characters.";
return;
}
try {
verifyButton.disabled = true;
statusOutput.textContent = `Calculating SHA-256 for ${file.name}…`;
const actual = await sha256Hex(await readFileBytes(file));
digestOutput.textContent = actual;
if (!expected) {
statusOutput.textContent = "Checksum calculated. Compare it with a trusted reference.";
} else if (actual === expected) {
statusOutput.textContent = "Match: the file bytes produce the expected SHA-256.";
} else {
statusOutput.textContent = "Mismatch: the file bytes do not produce the expected SHA-256.";
}
} catch (error) {
console.error(error);
statusOutput.textContent = "The browser could not calculate this checksum.";
} finally {
verifyButton.disabled = false;
}
});
Why “local” needs a precise meaning
With this workflow, the file bytes do not need to leave the browser for the calculation. Verify that claim in the Network panel: selecting a file should not trigger a request carrying the APK. Review analytics and third-party scripts separately; a local hashing function does not describe every script on a page.
For a working example, I maintain a browser-based APK SHA-256 verifier that follows this local-processing model and explains how to interpret the result.
What a checksum match proves
If an independently obtained expected SHA-256 matches the calculated value, the selected file has the same byte sequence as the file represented by that reference checksum, within the practical collision resistance of SHA-256.
That helps with:
- confirming that a download was not corrupted;
- identifying an exact APK build;
- comparing two copies without relying on file names; and
- recording a stable fingerprint for later checks.
The independence of the reference matters. If an APK and its displayed hash come from the same compromised source, a match only proves internal consistency between those two items.
What it does not prove
A SHA-256 match does not establish:
- that the APK is free from malicious code;
- that requested permissions are appropriate;
- that the signer is trustworthy;
- that the app will behave safely at runtime;
- that advertised features work; or
- that the file is an official release.
Static analysis can inspect a manifest, permissions, package identity, resources, and certificate information. Runtime analysis can observe network traffic, file access, processes, and behavior in a controlled device or emulator. Those conclusions should not be substituted with a checksum result.
Large APKs and memory use
file.arrayBuffer() loads the complete file into memory. This keeps the example small but can pressure memory for very large APKs, especially on mobile devices.
Web Crypto does not provide incremental hashing. If large-file support is a requirement, use a reputable streaming SHA-256 implementation in a Web Worker. Chunked reading limits peak memory use, and a worker prevents heavy calculation from blocking the interface.
Test the verifier
Use a small known file first:
- calculate its SHA-256 with a trusted command-line tool;
- run the browser verifier against the same file;
- confirm that both hexadecimal values match; and
- change one byte and confirm that the digest changes.
# Linux
sha256sum app.apk
# macOS
shasum -a 256 app.apk
# Windows PowerShell
Get-FileHash -Algorithm SHA256 .\\app.apk
Testing both a match and a mismatch is more useful than checking only the happy path.
Final boundary
Checksums are excellent identity evidence when the expected digest comes from a trustworthy, independent reference. Keep the conclusion narrow: the bytes match, or they do not.
Treat safety, provenance, signing trust, permissions, and runtime behavior as separate claims that need separate evidence.
The full project is available in the APK SHA-256 verifier GitHub repository.
Disclosure: I maintain the linked browser verifier and its GitHub repository.
Top comments (0)