← Blog
§ Privacy · 2026-05-25 · 6 min read

Browser-side SHA-256: how WebCrypto makes the privacy story work

The biggest single privacy claim receipts.you makes is the image bytes never leave your browser. This claim rests entirely on one API: SubtleCrypto.digest, the SHA-256 implementation that ships with every modern browser as part of WebCrypto. This post walks through what that API does, what it doesn't do, and how to verify the privacy claim yourself in five minutes of DevTools.

The privacy claim, exactly

When you drop a file on receipts.you/seal:

  1. Your browser reads the file into an ArrayBuffer.
  2. Your browser computes a SHA-256 hash of the buffer using SubtleCrypto.digest.
  3. Your browser computes pHash and dHash by decoding the image to pixels (also browser-side) and running the hash algorithms.
  4. The three hashes (32 bytes + 8 + 8 ≈ 48 bytes plus a small JSON envelope) are POSTed to our worker.
  5. The image bytes are never sent.

The privacy claim is verifiable: every outbound network request is visible in DevTools → Network. There is no path by which the image bytes leave your browser, because the only outbound request is the hash POST.

What WebCrypto provides

WebCrypto's SubtleCrypto interface exposes a small set of primitives:

  • digest — hash a buffer (SHA-256, SHA-384, SHA-512).
  • sign / verify — sign or verify with a key.
  • encrypt / decrypt — symmetric and asymmetric crypto.
  • generateKey, importKey, exportKey — key management.

We use digest exclusively on the client side; the signature happens server-side with our key. The whole hashing operation for a typical 2 MB screenshot completes in single-digit milliseconds on modern hardware.

The code, in plain JavaScript

async function sha256OfFile(file) {
  const buffer = await file.arrayBuffer();
  const hashBuffer = await crypto.subtle.digest("SHA-256", buffer);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
}

const hex = await sha256OfFile(file);
// hex is a 64-character hex string. Post this; never post 'file'.

That's the whole privacy story, structurally. Six lines of JavaScript. The hash is one-way: you can't reverse SHA-256 to recover the original bytes. Anyone receiving the hash has no way to reconstruct the image.

Verifying the claim in DevTools

Five-minute self-verification protocol:

  1. Open /seal in any browser.
  2. Open DevTools → Network tab. Clear the log.
  3. Filter by “Fetch/XHR” to ignore static asset loads.
  4. Drop a screenshot on the page.
  5. Look at what was POSTed to /api/seal. The request body is JSON containing:
    • A 64-character hex SHA-256 hash.
    • Two 16-character hex perceptual hashes.
    • Optional metadata (notes, source URL — only if you typed them).
  6. Note what is NOT in the POST: the file itself, the file's base64, the file's any-other-encoding. No image data.

If we were uploading the image, you'd see a request body of ~2 MB (or a multipart-form-data with a binary part). The request body is in the low hundreds of bytes; there's nowhere for the image to be hiding.

Why this matters past privacy

The hash-only architecture buys us three things beyond just the privacy story:

  • Subpoena exposure is minimal. A subpoena to receipts.you yields the hash + signature + timestamp + country code. The image content was never on our servers. There's no “please decrypt the file” conversation to be had.
  • Storage cost is trivial. ~400 bytes per receipt. Cloudflare D1's free tier carries millions of receipts comfortably. This is part of why the service can stay free.
  • GDPR / data-residency posture is simple. We process no personal data beyond an IP address (held for rate limiting, deletable on request). The hash isn't PII; the signature isn't PII.

The honest limits

  • You have to trust the JavaScript we ship. A malicious version of our client could secretly upload the image alongside the hash. Defenses: open-source client (you can audit), Subresource Integrity for any third-party scripts, DevTools network inspection (you can verify each session).
  • WebCrypto requires HTTPS. The API only works on secure origins. Our entire site is HTTPS-only; this isn't a limitation in practice.
  • SHA-256 is one-way; perceptual hashes are weaker. A pHash can be partially inverted to a low-resolution silhouette, but the result is far from a recoverable image. The privacy claim covers content reconstruction, not perfect zero-information leakage.

The source code

The hashing path is at receipts/marketing/lib/imageData.ts and receipts/marketing/lib/phash.ts on GitHub. It's about 200 lines of TypeScript with no dependencies beyond WebCrypto and the standard Canvas 2D API. Read it; the privacy story is shorter than this blog post.

Drop a screenshot →
free · no signup · stays in your browser