Upload Files to S3 Directly from the Browser
24 Dec 2025Routing file uploads through your server means your server pays for the bandwidth and the memory to buffer every file twice. A presigned URL lets the browser upload straight to S3 instead.
The flow
- Browser asks your server for a presigned URL for a specific key.
- Your server generates it using its AWS credentials — the browser never sees them.
- Browser PUTs the file directly to S3 using that URL.
- S3 enforces everything in the URL: which key, which content type, how long the URL is valid.
Your server touches metadata, never the file bytes.
As a sequence:
sequenceDiagram
participant Browser
participant Server
participant S3
Browser->>Server: request presigned URL for a key
Server->>S3: sign PutObject with IAM credentials
Server-->>Browser: presigned URL
Browser->>S3: PUT file directly
S3-->>Browser: 200 OK
Generating the URL
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
const client = new S3Client({ region: 'us-east-1' })
app.post('/upload-url', async (req, res) => {
const key = `uploads/${crypto.randomUUID()}-${req.body.filename}`
const command = new PutObjectCommand({
Bucket: 'my-bucket',
Key: key,
ContentType: req.body.contentType,
})
const url = await getSignedUrl(client, command, { expiresIn: 300 })
res.json({ url, key })
})
Five minutes (expiresIn: 300) is plenty for an upload that starts right after the browser gets the URL. Don’t make it long-lived — a leaked presigned URL is a write credential to your bucket until it expires.
Uploading from the browser
await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
})
The Content-Type header on the actual PUT must match what you signed the URL with. Mismatch it and S3 returns a 403 — this is the single most common failure people hit and mistake for a permissions problem.
CORS: the part that actually breaks
S3 buckets have no CORS config by default, which means a browser upload from your app’s origin gets blocked before it even reaches AWS — you’ll see it as a CORS error in the console, not an S3 error. Set a CORS policy on the bucket:
[
{
"AllowedOrigins": ["https://yourapp.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"],
"ExposeHeaders": ["ETag"]
}
]
Don’t use "AllowedOrigins": ["*"] for a PUT policy in production — it lets any site embed your upload flow. Scope it to your actual domains.
Locking down what gets uploaded
A presigned URL for PutObjectCommand doesn’t enforce file size by default. Someone with the URL (intentionally or by inspecting network traffic) can PUT a much larger file than you intended, unless you constrain it:
const command = new PutObjectCommand({
Bucket: 'my-bucket',
Key: key,
ContentType: req.body.contentType,
ContentLength: req.body.expectedSize, // S3 rejects mismatches
})
Or use a presigned POST (createPresignedPost) instead of PUT — it supports explicit size range conditions (["content-length-range", 0, 10485760]) enforced by S3 itself, which PUT presigning doesn’t give you as cleanly.
Keep your server in the loop for validation
Direct upload means your server never sees the file, which also means it can’t reject a bad file type or scan it before it lands in the bucket. Common pattern: upload to a quarantine prefix, trigger a Lambda on s3:ObjectCreated, validate/scan there, then move or tag the object as clean. Don’t serve uploaded files directly from the quarantine prefix to other users until that check passes.
Gotcha
Presigned URLs are signed with your server’s IAM credentials at generation time — if that IAM role’s permissions change or its temporary credentials expire between generating the URL and the browser using it, the upload fails with a signature error that has nothing to do with the URL’s own expiresIn. If uploads that used to work start failing everywhere at once, check whether the underlying IAM role or credentials changed before debugging CORS or content-type again.