Skip to main content

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 HeadObject says.

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-SignedHeadersMeaning
hostOnly the bucket host is signed. Any type and any size will be accepted.
content-length;hostSize is locked. Type is not.
content-length;content-type;hostSize 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".

tip

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​

Rule

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.

Rule

Always confirm with HeadObject before the record goes live. The client's word that "the upload worked" is not proof.

Rule

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.

tip

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.

tip

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.

tip

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-crc32 of an empty body to the URL. S3 compared the real body against it and refused. It happened twice in different features. Set requestChecksumCalculation: "WHEN_REQUIRED" on the presigning client.
  • The code said Content-Type was signed. It was not. The URL showed X-Amz-SignedHeaders=host only. Pass signableHeaders and 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 a content-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.
  • ContentType and ContentLength are on the command, and signableHeaders lists both.
  • The S3 client uses requestChecksumCalculation: "WHEN_REQUIRED".
  • I printed a URL and saw content-length;content-type;host and no checksum parameter.
  • Expiry is a few minutes.
  • The key has no client file name in it.
  • Confirm uses HeadObject and compares size and type.
  • The task role can PutObject on this prefix in every environment.
  • Bucket CORS allows PUT from the web origins that upload.
  • The client retries 5xx on the same URL and does not retry 4xx.