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
@aws-sdk/client-s3and@aws-sdk/s3-request-presignerv3 (3.600+).- 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. - Bucket CORS allowing the
x-amz-checksum-sha256request header (or*). - An upload endpoint that accepts
{ size, type, sha256 }and returns the signed URL plus the headers to send.
What the signature now covers
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
ChecksumSHA256on the command plussignableHeaders. Setting the checksum on the command addsx-amz-checksum-sha256to the request; listing it insignableHeadersforces 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-8to a text type and breaking the signature. BadDigesthandling. A 400 withBadDigestmeans 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.verifyStoredwithChecksumMode: "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
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.
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.