Binding Checksums into Presigned PUT URLs

Have the browser hash the file before asking for an upload URL, send the base64 digest with the request, and sign it into the PutObjectCommand as ChecksumSHA256 (or ChecksumCRC32C) so the header becomes part of the signature; the browser must then send x-amz-checksum-sha256 with exactly that value, S3 recomputes the checksum over the body it receives, and any mismatch — corruption, truncation or a substituted file — is rejected with BadDigest before an object is created.

A presigned PUT already pins the bucket, key and, if you sign it, the length. It does not pin the content: anyone holding the URL can upload any bytes of the right size. Binding a checksum closes that gap and gives you end-to-end integrity for free — the object that lands in S3 is byte-for-byte the file the user’s browser hashed, and its checksum is stored with the object for later verification. This page belongs to S3 presigned URL workflows in backend validation and cloud storage architecture. The multipart equivalent is verifying uploads with S3 additional checksums.

When to use this approach

  • Single-request uploads (up to a few hundred megabytes) where you want guaranteed integrity and content binding.
  • You want the stored object to carry a verifiable SHA-256 for deduplication, audit or legal hold.
  • Presigned URLs may pass through logs, proxies or third-party SDKs where substitution is a concern.

Prerequisites

  1. @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner v3 (3.600+).
  2. Browser hashing: Web Crypto crypto.subtle.digest("SHA-256", …) for files that fit in memory, or a streaming implementation for larger ones — see computing file checksums in the browser with Web Crypto.
  3. Bucket CORS allowing the x-amz-checksum-sha256 request header (or *).
  4. An upload endpoint that accepts { size, type, sha256 } and returns the signed URL plus the headers to send.

What the signature now covers

Fields bound by a presigned PUT with a signed checksum A plain presigned PUT signs the method, bucket, key and expiry. Adding Content-Length pins the size. Adding x-amz-checksum-sha256 pins the content: S3 hashes the received body and rejects it if the digest differs. Only the final combination guarantees the stored object is the exact file the client hashed. Each signed field removes one freedom from the URL holder method + bucket + key + expiry any bytes, any size, this key + Content-Length any bytes, exactly N of them + x-amz-checksum-sha256 only the bytes the client hashed S3 computes SHA-256 over the received body and compares before committing the object. Mismatch → 400 BadDigest, and nothing is written.
With the checksum signed, a presigned URL authorises one specific file rather than one specific slot.

Implementation

Browser: hash, request a URL with the digest, upload with the header.

async function sha256Base64(file: Blob): Promise<string> {
  // Fine up to a few hundred MB; stream-hash larger files with a WASM implementation.
  const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
  return btoa(String.fromCharCode(...new Uint8Array(digest)));
}

export async function uploadWithChecksum(file: File): Promise<{ key: string }> {
  const sha256 = await sha256Base64(file);
  const res = await fetch("/api/uploads", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ size: file.size, type: file.type, sha256 }),
  });
  if (!res.ok) throw new Error(`could not get upload URL: HTTP ${res.status}`);
  const { url, key, headers } = (await res.json()) as { url: string; key: string; headers: Record<string, string> };

  const put = await fetch(url, { method: "PUT", body: file, headers });
  if (put.status === 400) {
    const body = await put.text();
    if (body.includes("BadDigest")) throw new Error("file changed or was corrupted in transit — please retry");
    throw new Error(`upload rejected: ${body.slice(0, 200)}`);
  }
  if (!put.ok) throw new Error(`upload failed: HTTP ${put.status}`);
  return { key };
}

Server: validate the digest format, sign it in, and return exactly the headers the client must send.

import { S3Client, PutObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { randomUUID } from "node:crypto";

const s3 = new S3Client({});
const BUCKET = process.env.UPLOAD_BUCKET!;
const B64_SHA256 = /^[A-Za-z0-9+/]{43}=$/;          // 32 bytes → 44 base64 chars

export async function issueChecksumUrl(userId: string, req: { size: number; type: string; sha256: string }) {
  if (!B64_SHA256.test(req.sha256)) throw Object.assign(new Error("sha256 must be base64 of 32 bytes"), { status: 400 });
  if (req.size <= 0 || req.size > 500 * 1024 * 1024) throw Object.assign(new Error("size out of range"), { status: 413 });

  const key = `uploads/${userId}/${randomUUID()}/source`;
  const url = await getSignedUrl(s3, new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    ContentType: req.type,
    ContentLength: req.size,
    ChecksumSHA256: req.sha256,
  }), {
    expiresIn: 900,
    signableHeaders: new Set(["content-type", "content-length", "x-amz-checksum-sha256"]),
  });
  return {
    url,
    key,
    headers: { "Content-Type": req.type, "x-amz-checksum-sha256": req.sha256 },
  };
}

/** After upload: prove the stored object carries the expected checksum. */
export async function verifyStored(key: string, expectedSha256: string): Promise<boolean> {
  const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key, ChecksumMode: "ENABLED" }));
  return head.ChecksumSHA256 === expectedSha256;
}

Line-by-line on the details that matter

  • ChecksumSHA256 on the command plus signableHeaders. Setting the checksum on the command adds x-amz-checksum-sha256 to the request; listing it in signableHeaders forces it into the signature rather than leaving it as a hoisted query parameter or unsigned header. The client cannot then omit it or change it.
  • Base64 of the raw digest, not hex. S3 checksum headers are base64 of the binary digest. A hex string fails with Value for x-amz-checksum-sha256 header is invalid. The regex rejects malformed values before signing.
  • Returning the headers to send. The browser must send precisely the signed headers. Returning them from the API keeps the two sides from drifting — for example, the browser adding ; charset=utf-8 to a text type and breaking the signature.
  • BadDigest handling. A 400 with BadDigest means the bytes differ from the hash: the file changed on disk after hashing, a proxy altered the body, or memory corruption. Retrying with a fresh hash is the right response; retrying the same URL will fail again.
  • verifyStored with ChecksumMode: "ENABLED". S3 returns stored checksums only when asked. Recording the verified SHA-256 on the asset makes it usable for deduplication later, as in deduplicating uploads with content hashes.

Hash first, then ask for the URL

Request sequence for a checksum-bound upload The browser hashes the file locally, then asks the API for an upload URL with the size, type and SHA-256. The API signs the checksum into the URL and returns it with the headers to send. The browser PUTs the file to S3 with the checksum header. S3 recomputes the hash and stores the object only if it matches. The hash must exist before the signature does browser your API S3 SHA-256 locally POST {size, type, sha256} {url, headers} PUT body + x-amz-checksum-sha256 200 if hash matches · 400 BadDigest if not Hashing costs a local read of the file before upload starts; for most files it is under a second.
Because the digest is part of the signature, the URL cannot be issued until the file has been hashed.

Choosing SHA-256 or CRC32C

The two algorithms answer different needs. SHA-256 is cryptographic: nobody can construct different content with the same digest, which makes it suitable for content binding against a malicious URL holder, deduplication across users, and audit. It costs more CPU — roughly 200–600 MB/s in the browser depending on implementation. CRC32C is designed for detecting accidental corruption, runs several times faster, and supports full-object checksums for multipart uploads; but a determined attacker can craft different content with the same CRC, so it does not bind content against misuse.

For single-request uploads from browsers, SHA-256 is usually the right choice: the files are small enough that hashing time is negligible next to upload time, and the stored digest doubles as a content identity. For multi-gigabyte multipart uploads, per-part CRC32C for integrity plus a separately computed whole-file SHA-256 for identity is the common combination.

Browser hashing time for a 50 MB photo set by algorithm Hashing 50 megabytes in a laptop browser takes about 0.15 seconds with Web Crypto SHA-256, about 0.25 seconds with WASM SHA-256 streaming, and about 0.05 seconds with WASM CRC32C. Uploading the same data at 20 megabits per second takes about 20 seconds. 50 MB in a laptop browser, seconds CRC32C (wasm) ≈ 0.05 s SHA-256 (Web Crypto) ≈ 0.15 s upload at 20 Mbit/s ≈ 20 s Hashing is noise next to transfer time; the cost that matters is memory for one-shot Web Crypto. Above ~200 MB, switch to streaming hashing so the file is never fully in memory.
Either algorithm is cheap relative to the upload; choose by whether you need identity or only integrity.

Using the checksum after the upload

Once a verified SHA-256 is attached to every object, several features become cheap.

Deduplication before upload. The client already has the hash before it asks for a URL. If your API finds an existing asset with the same digest owned by the same user or tenant, it can skip the upload entirely and link the existing object — an instant “upload” for re-sent files. Keep this within a tenant; cross-tenant deduplication leaks information about other tenants’ files through timing.

Tamper evidence. Recording the digest in your database at confirmation time, and again in audit logs, lets you prove later that an object has not changed: fetch it with checksum mode enabled and compare. For regulated content — contracts, medical images, evidence — this is often a compliance requirement rather than a nice-to-have.

Safe re-processing. Processing pipelines can key their jobs on the content hash instead of the object key, so re-uploads of the same bytes reuse existing derivatives and genuinely new content always gets new ones, as in making media jobs idempotent with content-hash keys.

Configuration gotchas

SignatureDoesNotMatch only when the checksum is included. The browser is not sending the header, or sends it with different casing or a trailing space. Send exactly the headers returned by the API.

CORS preflight fails after adding the checksum. x-amz-checksum-sha256 is not a safelisted header. Add it to the bucket CORS AllowedHeaders.

XAmzContentSHA256Mismatch. Different from BadDigest: it concerns the SigV4 payload hash header, which presigned URLs set to UNSIGNED-PAYLOAD. It appears when custom signing code sets x-amz-content-sha256 incorrectly — let the presigner handle it.

Hash mismatch for files edited during upload. A user saves the document again after selecting it. The file on disk no longer matches the hash; S3 rejects with BadDigest. Detect with a size/lastModified check before uploading and re-hash.

Verification

SHA=$(openssl dgst -sha256 -binary photo.jpg | base64)
RESP=$(curl -s -X POST localhost:8080/api/uploads -H 'Content-Type: application/json' \
  -d "{\"size\":$(stat -c %s photo.jpg),\"type\":\"image/jpeg\",\"sha256\":\"$SHA\"}")
URL=$(jq -r .url <<<"$RESP")

# The right file: 200.
curl -s -o /dev/null -w '%{http_code}\n' -X PUT -H 'Content-Type: image/jpeg' \
  -H "x-amz-checksum-sha256: $SHA" --data-binary @photo.jpg "$URL"

# A different file of the same size: 400 BadDigest.
head -c "$(stat -c %s photo.jpg)" /dev/urandom > same-size.bin
curl -s -X PUT -H 'Content-Type: image/jpeg' -H "x-amz-checksum-sha256: $SHA" --data-binary @same-size.bin "$URL" | grep -o BadDigest

Frequently Asked Questions

Is Content-MD5 an alternative?

For single-part uploads, signing Content-MD5 also makes S3 verify content, and it works everywhere. SHA-256 is preferable because MD5 is not collision-resistant, and the additional-checksum headers store the value on the object for later retrieval.

Does this work with presigned POST?

Presigned POST policies can include x-amz-checksum-* as a form field with an exact-match condition, which gives the same binding for form-based uploads. See presigned POST vs presigned PUT for browser uploads for the policy format.

Can the server compute the hash instead?

Only after the upload, by reading the object back — which proves what was stored but not that it matches what the user sent. Client-side hashing plus a signed checksum is what gives end-to-end assurance.