How to Upload Avatars to AWS S3 With a Presigned URL
Once your backend mints an AWS S3 presigned url, the client uploads the file bytes straight to S3 - the backend never touches the bytes. The whole client side is a single fetch PUT with the exact Content-Type the URL was signed for; a correct upload returns HTTP 200. Here is the code.
Why upload direct to S3
When a user picks an avatar, the naive path is: browser POSTs the file to your API, your API forwards the bytes to S3. That makes your backend a proxy for every byte - it burns bandwidth, adds latency, and hits payload ceilings (a Lambda request body caps at 6MB).
The presigned-URL pattern removes the backend from the data path. Your server signs a short-lived URL that grants permission to PUT one object, and the client uploads directly to S3. The only thing the backend did was mint the URL. The upload skips it entirely.
This page is the client side of that flow - the PUT. (Generating the URL server-side with boto3 is a separate step; see the related project.)
The client upload is one fetch PUT
The backend hands you a presigned URL. To upload, send the raw bytes with a plain HTTP
PUT and set the Content-Type the URL was signed for. In Node 18+ (and every modern
browser) fetch is global, and the body can be a Buffer or a Blob/File:
async function upload(bytes, signedUrl) {
const r = await fetch(signedUrl, {
method: "PUT",
headers: { "Content-Type": "image/png" },
body: bytes,
});
return r.status;
}
module.exports = { upload };
That is the entire client. No AWS SDK, no credentials in the browser - the signature is
already baked into the URL. A successful upload returns 200.
The Content-Type has to match what was signed
This is the one thing that bites people. The presigned URL was generated with a specific
ContentType in its parameters:
# backend, at sign time
boto3.client("s3").generate_presigned_url(
"put_object",
Params={"Bucket": "user-avatars", "Key": "avatars/test.png",
"ContentType": "image/png"},
ExpiresIn=3600,
)
Because ContentType was part of the signature, the client must send the exact same
header. Send image/jpeg, or omit the header, and the signature no longer matches - S3
rejects the PUT with a 403 SignatureDoesNotMatch. If you get a 403 on a URL that was
valid a second ago, the header is the first thing to check.
Verifying the object landed
After the PUT returns 2xx, the object is in the bucket. You can confirm it and check the bytes actually arrived (not an empty PUT) with the AWS CLI:
aws s3 ls s3://user-avatars/avatars/
aws s3 cp s3://user-avatars/avatars/test.png /tmp/check.png
xxd -p -l 8 /tmp/check.png # PNG magic: 89504e470d0a1a0a
If the first 8 bytes are the PNG magic header, the file uploaded intact. A common failure
is passing the wrong thing as body (a string path instead of the bytes) - the PUT still
returns 200 but the object is empty or wrong, so verifying the header catches it.
Production hardening
A real avatar feature adds a few things on top of the bare PUT: progress events (via
XMLHttpRequest or a streaming fetch wrapper) so the UI can show a bar, client-side
resize/compress before upload, a short URL TTL (5 minutes) so a leaked URL expires fast,
and an S3 ObjectCreated trigger that scans or resizes the image before it goes live.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Uploading file bytes directly to S3 with a single fetch PUT
- Matching the client Content-Type header to the signed URL's ContentType
- Verifying the uploaded object's bytes with aws s3 cp and the PNG magic header
FAQ
How do I upload a file to an S3 presigned URL?
Send a plain HTTP PUT to the presigned URL with the file bytes as the body and the Content-Type the URL was signed for. In JavaScript that is a single fetch call with method PUT; no AWS SDK or credentials are needed on the client. A successful upload returns HTTP 200.
Why does my S3 presigned URL upload return 403 SignatureDoesNotMatch?
The most common cause is a Content-Type mismatch. If the URL was signed with a ContentType parameter, the client must send the exact same Content-Type header on the PUT. Sending a different value, or omitting the header, breaks the signature and S3 returns 403.
Do I need the AWS SDK in the browser to upload with a presigned URL?
No. The signature is embedded in the presigned URL itself, so the client just does a normal PUT with fetch or XMLHttpRequest. Keeping the AWS SDK and credentials on the backend is the whole point - the browser never sees them.
Can I use fetch to PUT to S3 from Node.js?
Yes. Node 18+ ships a global fetch, so you can PUT bytes to a presigned S3 URL with no extra library. The request body can be a Buffer, and you set the Content-Type header to match what the URL was signed for.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →