Presigned PUT
Use this for one small file whose exact size the client knows before it starts, like an avatar or a cover image. The server signs a URL. The client PUTs the file to S3. The client then asks the API to confirm.
How it works
Three calls, and the file only travels once (client to S3).
- Steps 1 to 5: the API decides everything: the key, the content type, the size and the expiry. The client only asks.
- Steps 6 and 7: the upload. S3 checks the signature. If a signed header does not
match, S3 refuses the upload with
403 SignatureDoesNotMatch. - Steps 8 to 13: the confirm. The client could lie about having uploaded. The API
only trusts what
HeadObjectsays.
How to build it
Server: sign the URL
import { randomUUID } from "node:crypto";
import { HeadObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
// WHEN_REQUIRED: see "Mistakes we made". The default bakes a checksum of an
// EMPTY body into the URL, and S3 then rejects the real file.
const s3 = new S3Client({ requestChecksumCalculation: "WHEN_REQUIRED" });
const BUCKET = "example-uploads";
const MAX_BYTES = 5 * 1024 * 1024;
const TTL_SECONDS = 300;
// The allowlist maps a MIME type to the extension WE use in the key.
const EXT: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
};
export async function presignAvatar(userId: string, contentType: string, byteSize: number) {
const ext = EXT[contentType];
if (!ext) throw new Error("unsupported_type");
if (!Number.isInteger(byteSize) || byteSize < 1 || byteSize > MAX_BYTES) {
throw new Error("bad_size");
}
const key = `user-uploads/avatars/${userId}/${randomUUID()}.${ext}`;
const url = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
ContentType: contentType,
ContentLength: byteSize,
}),
{
expiresIn: TTL_SECONDS,
// Without this, Content-Type is NOT part of the signature.
signableHeaders: new Set(["content-type", "content-length"]),
},
);
// Save a pending row with `key`, `contentType` and `byteSize` here.
return { url, key, headers: { "Content-Type": contentType } };
}
Client: upload the bytes
async function uploadAvatar(file: File) {
const { url, key, headers } = await api.presignAvatar({
contentType: file.type,
byteSize: file.size, // must be the exact size, it is in the signature
});
const res = await fetch(url, { method: "PUT", headers, body: file });
if (!res.ok) throw new Error(`upload_failed_${res.status}`);
return api.confirmAvatar({ key });
}
The browser sets Content-Length itself from the file. You cannot set it by hand.
That is why the client must declare file.size exactly.
Server: confirm with HeadObject
export async function confirmAvatar(userId: string, key: string) {
const row = await db.findPendingUpload(userId, key); // only keys WE minted for THIS user
if (!row) throw new Error("not_found");
let head;
try {
head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }));
} catch {
throw new Error("not_uploaded"); // the client never finished the PUT
}
if (head.ContentLength !== row.byteSize || head.ContentType !== row.contentType) {
await deleteObject(key);
throw new Error("upload_mismatch");
}
await db.markUploaded(row.id);
}
Check what you actually signed
Print one URL and read its X-Amz-SignedHeaders query parameter.
X-Amz-SignedHeaders | Meaning |
|---|---|
host | Only the bucket host is signed. Any type and any size will be accepted. |
content-length;host | Size is locked. Type is not. |
content-length;content-type;host | Size and type are locked. This is what you want. |
Also check the URL has no x-amz-checksum-crc32 parameter. If it has one, the
S3 client is missing requestChecksumCalculation: "WHEN_REQUIRED".
We tested this with @aws-sdk/s3-request-presigner 3.x (October 2026). Setting
ContentType on the command was not enough to sign it. Setting
ContentLength was. Do not trust a code comment that says "the signature
encodes the content type". Print the URL and look.
Key naming
Build keys like this:
user-uploads/<feature>/<owner id>/<random uuid>.<ext from our allowlist>
- No client file name. It can hold a person's name, it can clash with
another file, and it can contain
../or odd characters. - The extension comes from our allowlist map, not from the file name.
- A random UUID per upload. Keys are never reused, so a new upload never overwrites an old one, and CDN caches never serve a stale copy.
- Owner id in the path. The confirm step can check "this key belongs to this user" with a simple prefix match. An IAM policy can also scope writes to a feature prefix.
- Anonymous uploads (a public form with no user yet) use two random UUIDs,
like
scratch/<uuid>/<uuid>.pdf. The key is unguessable, and a lifecycle rule expires the scratch prefix.
Only ever sign a GET for a key that matches the exact shape your code mints. A regex check on the key stops the signer from being used to read any object in the bucket.
Expiry
Keep it short: 5 minutes is plenty for a small file.
- A presigned URL can be used many times until it expires. A long expiry means a long window to overwrite the file after you checked it.
- A URL signed with role credentials (ECS task, Lambda) dies when those credentials expire, even if you asked for longer. AWS documents that ECS task credentials typically rotate every 1 to 6 hours. A long expiry would not hold anyway.
- If a URL expires mid-flow, the client asks for a new one. That is cheap.
Retrying a failed upload
- Retry the same URL while it is valid. It costs no new presign and no new bot check.
- Do not retry a 4xx. If S3 refused the signature once, it will refuse it again.
- When the user sends several files, stop at the first failure that cannot be fixed. Carrying on only creates orphan files.
- Show the user the real reason. "Check your connection" for every failure hides real bugs.
Rules
Sign both Content-Type and Content-Length, using signableHeaders. If they
are not in the signature, a client can upload any type and any size to a URL
meant for a 1 MB image.
Always confirm with HeadObject before the record goes live. The client's word
that "the upload worked" is not proof.
Build the S3 client with requestChecksumCalculation: "WHEN_REQUIRED" for any
client that presigns uploads. The SDK default signs a checksum of an empty body,
and S3 then rejects the real one.
Content-Type is a label the client chose. It does not prove the bytes are a
JPEG. If that matters, read the first bytes with a ranged GET, or let the
moderation step fail files it cannot decode.
Signing a URL needs no S3 permission. If the role lacks s3:PutObject on the
prefix, the presign still succeeds and the upload 403s in the browser. Apply the
IAM change in an environment before the code reaches it.
S3 supports conditional writes. A PutObject with If-None-Match: * refuses to
overwrite an existing object. Consider it when a re-upload after confirm would
be harmful.
Mistakes we made
- Uploads failed with 403 after a correct presign. The SDK added an
x-amz-checksum-crc32of an empty body to the URL. S3 compared the real body against it and refused. It happened twice in different features. SetrequestChecksumCalculation: "WHEN_REQUIRED"on the presigning client. - The code said Content-Type was signed. It was not. The URL showed
X-Amz-SignedHeaders=hostonly. PasssignableHeadersand check the URL. - Size was a client promise only. The early signers left size checks to the
client. A caller could declare 1 KB and upload far more. Sign
Content-Length, or use Presigned POST with acontent-length-range. - The client turned every failure into one message. Ten different causes all showed as "check your connection". Nobody could debug it from user reports.
Checklist
- The type allowlist maps MIME type to our extension.
- The size cap is checked before signing.
-
ContentTypeandContentLengthare on the command, andsignableHeaderslists both. - The S3 client uses
requestChecksumCalculation: "WHEN_REQUIRED". - I printed a URL and saw
content-length;content-type;hostand no checksum parameter. - Expiry is a few minutes.
- The key has no client file name in it.
- Confirm uses
HeadObjectand compares size and type. - The task role can
PutObjecton this prefix in every environment. - Bucket CORS allows
PUTfrom the web origins that upload. - The client retries 5xx on the same URL and does not retry 4xx.