Our API server was routing every user file upload through itself, receiving the full file, buffering it, then re-uploading it to S3, which meant a handful of concurrent large uploads could tie up API server memory and connection slots that should have been serving actual requests. Switching to presigned URLs, where the browser uploads directly to S3 and the API's only job is issuing a short-lived signed permission slip, removed the API entirely from the file transfer's data path, and getting the security constraints on that signed URL right mattered more than the happy-path upload code itself.
A presigned URL is a regular S3 request URL with the authentication normally provided by AWS credentials embedded directly into the query string as a signature, computed using the server's real credentials but valid only for the specific operation and time window it was signed for. The browser holding this URL can perform exactly that one operation, a PUT of a specific key, without ever possessing an actual AWS credential itself, which is the entire point: the client gets narrow, time-boxed permission without broad access.
The AWS SDK v3's presigning is a separate package from the main S3 client, s3-request-presigner, that takes a client instance and an unsent command object and returns the signed URL without ever actually issuing the request. Setting a reasonably short expiresIn, a few minutes rather than hours, limits how long a leaked or intercepted presigned URL would remain exploitable.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: 'us-east-1' });
app.post('/uploads/presign', authenticate, async (req, res) => {
const { filename, contentType } = req.body;
const key = `uploads/${req.user.id}/${crypto.randomUUID()}-${filename}`;
const command = new PutObjectCommand({
Bucket: process.env.UPLOAD_BUCKET,
Key: key,
ContentType: contentType,
});
const url = await getSignedUrl(s3, command, { expiresIn: 300 });
res.json({ uploadUrl: url, key });
});
Including ContentType in the signed command isn't just metadata, it becomes part of what the signature covers, meaning the browser's actual PUT request must send that exact Content-Type header or S3 rejects the request as a signature mismatch. This is a real security control, not an inconvenience: without it, a signed URL intended for an image upload could be used to upload anything, including an HTML file that, depending on bucket configuration, might be served back with a content type that executes as a script in a browser.
The client doesn't need any AWS SDK at all, the presigned URL is just a regular URL, and uploading to it is a plain fetch PUT with the file as the body and a matching Content-Type header, nothing S3-specific about the request from the browser's perspective.
async function uploadFile(file) {
const { uploadUrl, key } = await fetch('/uploads/presign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: file.name, contentType: file.type }),
}).then((r) => r.json());
const uploadRes = await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
});
if (!uploadRes.ok) throw new Error('upload failed');
return key; // store this key in your own database, tied to the record it belongs to
}
A plain presigned PutObjectCommand has no built-in size limit, a malicious client could upload something enormous straight to your bucket and run up storage costs before you'd notice. Using a presigned POST instead of a presigned PUT, via createPresignedPost, lets you attach explicit policy conditions, a content-length-range among them, that S3 itself enforces at upload time, rejecting an oversized file before it's even fully received rather than after the fact.
Direct browser-to-S3 uploads require the bucket's CORS configuration to explicitly allow PUT from your frontend's origin, and I initially left AllowedHeaders too permissive with a wildcard, which worked but is broader than necessary. Scoping AllowedHeaders down to specifically content-type and the handful of headers the actual upload needs, rather than a blanket wildcard, keeps the CORS policy from being more permissive than the upload flow actually requires.
Because the API never sees the file itself, it has no automatic way of knowing the upload succeeded unless the client tells it, and a client can fail silently between generating the presigned URL and completing the PUT. Requiring the client to call a confirmation endpoint after a successful upload, which the server then verifies with a HeadObjectCommand against S3 rather than just trusting the client's word, closes the gap between "we issued a URL" and "the file genuinely exists in the bucket" before marking anything as complete in the database.
Removing the API server from the file transfer path is a real architectural win, no more buffering large uploads through application memory, but the security details, scoping the signature to content type, constraining size with a presigned POST policy instead of a bare PUT, and verifying the upload actually landed, are what make this safe to expose to untrusted browser clients rather than just convenient.