Build Secure File Uploads with AWS S3 Presigned URLs
Learn how to build secure direct-to-S3 file uploads using presigned URLs. This practical tutorial shows how your backend can authorize an upload while the browser sends the file directly to Amazon S3.

Tools used: AWS Console, AWS CLI, Node.js, npm, VS Code, Git, GitHub
Prerequisites: Basic JavaScript, Node.js, REST APIs, and familiarity with environment variables.
Build Secure File Uploads with AWS S3 Presigned URLs
Uploading a file is easy.
Building a file-upload system that remains secure, scalable, and efficient is a different problem.
A common implementation sends the entire file through your backend:
Browser
│
│ file
▼
Backend
│
│ file
▼
Amazon S3That works for small applications.
But as files become larger and users increase, your API server starts handling traffic that it does not really need to handle.
A better approach is to let your backend authorize the upload and let the browser send the file directly to Amazon S3 using a temporary presigned URL.
Browser
│
│ "Can I upload this file?"
▼
Backend
│
│ creates temporary URL
▼
Browser
│
│ PUT file
▼
Amazon S3The backend controls access.
S3 handles the file.
That simple change can make a big difference in how you design file uploads.
What we're building
By the end of this tutorial, you'll have a flow like this:
┌──────────────┐
│ Frontend │
│ │
│ Select file │
└──────┬───────┘
│
│ POST /uploads/presigned-url
▼
┌──────────────────────┐
│ Node.js API │
│ │
│ Authenticate user │
│ Validate file │
│ Generate S3 URL │
└──────────┬───────────┘
│
│ temporary URL
▼
┌──────────────────────┐
│ Frontend │
│ │
│ PUT file directly │
└──────────┬───────────┘
│
│
▼
┌──────────────────────┐
│ Amazon S3 │
│ │
│ uploaded file │
└──────────────────────┘We'll use:
- Node.js
- Express
- AWS SDK for JavaScript v3
- Amazon S3
- JavaScript
- Browser
fetch()
AWS supports presigned URLs for temporary S3 access, including uploading objects without giving the uploading party AWS credentials.
Start with the real problem
Imagine a job platform where employers upload:
- company logos
- job banners
- resumes
- PDF documents
- recruitment assets
A traditional upload might look like:
User
│
│ 25 MB file
▼
Node.js Server
│
│ 25 MB file
▼
Amazon S3Your Node.js server becomes responsible for receiving and forwarding the entire file.
With direct S3 upload:
User
│
│ 25 MB file
└──────────────────────────► S3
small authorization request
│
▼
BackendThe backend no longer needs to carry the entire upload.
This is especially useful when your application needs to support larger files or many concurrent uploads.
The important idea: authorization and storage are separate
The backend should answer:
"Is this user allowed to upload this file?"
S3 should answer:
"Can this signed request upload the object?"
That gives us a clean separation:
Authentication
↓
Authorization
↓
Validation
↓
Presigned URL
↓
S3 UploadDon't skip the first three steps.
A presigned URL is temporary access to an S3 operation. It is not a replacement for your application's authentication or authorization system.
1. Create the S3 bucket
Create a general-purpose S3 bucket in your AWS account.
For example:
karyvio-user-uploadsThe exact bucket name can be different because S3 bucket names have their own naming requirements.
For a normal private application, keep the bucket private.
Your application should not need to make the entire bucket publicly writable just because users need to upload files.
A better model is:
Private bucket
+
IAM permissions
+
Application authorization
+
Presigned URLS3 stores files as objects inside buckets. Each object has a key that identifies its location.
2. Decide your object-key structure
Don't immediately do this:
const key = file.name;A user's filename should not determine your entire storage structure.
Instead, generate an application-controlled key.
For example:
users/1001/profile/8e4b2a1c.pngor:
companies/501/logo/7e91c2f4.pngA larger application might organize objects like this:
uploads/
├── users/
│ ├── 1001/
│ │ ├── profile/
│ │ └── documents/
│ │
│ └── 1002/
│ ├── profile/
│ └── documents/
│
├── companies/
│ ├── 501/
│ │ ├── logo/
│ │ └── banners/
│ │
│ └── 502/
│
└── temporary/The original filename can be stored separately in your database.
For example:
{
"originalName": "my-resume.pdf",
"s3Key": "users/1001/documents/8e4b2a1c.pdf"
}This gives your application control over the storage key.
3. Install the AWS packages
For a Node.js backend using AWS SDK for JavaScript v3:
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner@aws-sdk/client-s3 provides the S3 client and commands.
@aws-sdk/s3-request-presigner provides the functionality used to create presigned URLs. AWS's current JavaScript SDK documentation uses this package for presigned URL generation.
4. Configure AWS credentials safely
For local development, your environment might contain:
AWS_REGION=ap-south-1
AWS_S3_BUCKET=karyvio-user-uploadsYou can configure AWS credentials through the AWS credential provider chain rather than hard-coding secrets into your application.
Avoid this:
const accessKey = "AKIA...";
const secretKey = "super-secret-value";And never commit credentials into Git.
Your .gitignore should normally include:
.envFor production workloads running on AWS, prefer an appropriate IAM role or other managed credential mechanism rather than putting long-lived access keys directly into application source code.
5. Create the S3 client
Create a small S3 configuration module:
import { S3Client } from "@aws-sdk/client-s3";
export const s3 = new S3Client({
region: process.env.AWS_REGION,
});The SDK can obtain credentials from the configured AWS credential provider chain.
6. Generate the upload key
Don't trust the client's filename as your unique storage identifier.
Use a generated identifier instead:
import crypto from "node:crypto";
const fileId = crypto.randomUUID();
const key = `uploads/${fileId}`;For a real application, you can make the structure more meaningful:
const fileId = crypto.randomUUID();
const key = `users/${userId}/documents/${fileId}`;You can also preserve the extension when appropriate:
const extension = ".pdf";
const key = `users/${userId}/documents/${fileId}${extension}`;The extension should come from validated application logic rather than blindly trusting arbitrary user input.
7. Validate before signing
This is where many beginner implementations go wrong.
The frontend might send:
{
"filename": "resume.pdf",
"contentType": "application/pdf"
}But the backend should not automatically trust it.
At minimum, decide which MIME types your application accepts.
For example:
const allowedTypes = new Set([
"image/jpeg",
"image/png",
"application/pdf",
]);
if (!allowedTypes.has(contentType)) {
return res.status(400).json({
message: "Unsupported file type",
});
}You should also establish a maximum file size appropriate for the specific feature.
For example:
Profile photo → small limit
Resume → moderate limit
Video → much larger limitThere is no universal "correct" upload size.
The limit should come from your product requirements.
8. Create the presigned URL
Now we reach the important part.
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export async function createUploadUrl({
bucket,
key,
contentType,
}) {
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: contentType,
});
return getSignedUrl(s3, command, {
expiresIn: 300,
});
}Here:
expiresIn: 300means the generated URL is intended to be valid for 300 seconds.
A short expiration is often appropriate for an upload authorization URL.
AWS documents that presigned URLs are time-limited and that their effective validity also depends on the credentials used to create them.
9. Build the API endpoint
Now connect the signing logic to an Express endpoint.
import express from "express";
import crypto from "node:crypto";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { s3 } from "./s3.js";
const router = express.Router();
const allowedTypes = new Set([
"image/jpeg",
"image/png",
"application/pdf",
]);
router.post("/uploads/presigned-url", async (req, res) => {
try {
const { contentType } = req.body;
if (!allowedTypes.has(contentType)) {
return res.status(400).json({
message: "Unsupported file type",
});
}
// In a real application, obtain this from
// the authenticated user/session.
const userId = req.user.id;
const fileId = crypto.randomUUID();
const key = `users/${userId}/uploads/${fileId}`;
const command = new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET,
Key: key,
ContentType: contentType,
});
const uploadUrl = await getSignedUrl(
s3,
command,
{
expiresIn: 300,
}
);
return res.json({
uploadUrl,
key,
fileId,
});
} catch (error) {
console.error("Presigned URL error:", error);
return res.status(500).json({
message: "Unable to create upload URL",
});
}
});
export default router;Notice something important:
The endpoint does not receive the actual file.
It only creates permission for the browser to upload the file.
10. The frontend asks for permission
Now the browser can request a URL:
const response = await fetch(
"/uploads/presigned-url",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
contentType: file.type,
}),
}
);
if (!response.ok) {
throw new Error("Unable to prepare upload");
}
const { uploadUrl, key } = await response.json();At this point:
Browser
│
│ request permission
▼
Backend
│
│ temporary URL
▼
BrowserThe file still hasn't gone to S3.
11. Upload the file directly to S3
Now use the returned URL:
const uploadResponse = await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Type": file.type,
},
body: file,
});
if (!uploadResponse.ok) {
throw new Error("S3 upload failed");
}The final path is:
Browser
│
│ PUT file
▼
Amazon S3Your Node.js server is not carrying the file.
12. One complete frontend function
The previous pieces can be combined into a reusable function:
async function uploadFile(file) {
const prepareResponse = await fetch(
"/uploads/presigned-url",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
contentType: file.type,
}),
}
);
if (!prepareResponse.ok) {
throw new Error("Could not prepare upload");
}
const {
uploadUrl,
key,
} = await prepareResponse.json();
const uploadResponse = await fetch(
uploadUrl,
{
method: "PUT",
headers: {
"Content-Type": file.type,
},
body: file,
}
);
if (!uploadResponse.ok) {
throw new Error("File upload failed");
}
return {
key,
};
}Usage:
const file = fileInput.files[0];
const result = await uploadFile(file);
console.log("Uploaded:", result.key);13. Your browser may need S3 CORS
There is another important piece when the browser and S3 are on different origins:
CORS.
For example:
https://karyvio.com
│
│ browser request
▼
Amazon S3The browser can enforce cross-origin restrictions.
S3 therefore needs a CORS configuration that allows the required origin, method, and headers. AWS documents AllowedOrigins, AllowedMethods, and AllowedHeaders as parts of the S3 CORS configuration.
For example, a development configuration could look like:
[
{
"AllowedOrigins": [
"http://localhost:3000"
],
"AllowedMethods": [
"PUT"
],
"AllowedHeaders": [
"*"
]
}
]For production, replace the development origin with the actual frontend origin.
Avoid using:
"AllowedOrigins": ["*"]unless your application's requirements genuinely justify allowing every origin.
CORS controls browser cross-origin behavior; it does not replace S3 permissions or IAM policies.
14. Why the Content-Type must match
Suppose your backend signs the request using:
ContentType: "application/pdf"Then the browser should send:
headers: {
"Content-Type": "application/pdf",
}If the signed request and actual upload request don't match where the signature covers that header, S3 can reject the request.
A common error is:
SignatureDoesNotMatchAWS specifically recommends checking the content type, URL, expiration, region, and system time when troubleshooting signature mismatches.
15. What happens when the URL expires?
Imagine this sequence:
10:00
│
├── URL generated
│
10:02
│
├── User selects file
│
10:06
│
└── Upload attemptedIf the URL was configured for five minutes, the client may need to request a new URL.
The frontend can handle that as:
Upload
│
├── Success → continue
│
└── Failure
│
├── request new URL
│
└── retryDon't solve expiration problems by simply making every URL valid for an unnecessarily long period.
The purpose of a presigned URL is temporary access.
16. A subtle S3 problem: object replacement
Suppose your presigned URL uses this key:
users/1001/profile/avatar.pngThen another upload uses exactly the same key.
The new object can replace the existing object.
AWS explicitly documents that uploading to an existing object key replaces the existing object.
For user-generated uploads, generated keys are often safer:
users/1001/profile/
8c6e2c4a-avatar.pnginstead of repeatedly writing:
users/1001/profile/avatar.pngIf replacement is intentional, that's fine.
If replacement is not intentional, generate unique keys.
17. Store metadata in your database
S3 knows about the object.
Your application needs to know what that object means.
A database record might look like:
{
"id": "upload_123",
"userId": "1001",
"originalName": "resume.pdf",
"s3Key": "users/1001/documents/8c6e2c4a.pdf",
"contentType": "application/pdf",
"status": "UPLOADED"
}This separation is useful:
Database
│
├── owner
├── original filename
├── upload status
├── application metadata
└── S3 object key
│
▼
S3
│
└── actual fileYour database answers:
Who owns this file?
S3 answers:
Where is the actual object?
18. Don't mark the upload complete too early
Consider this flow:
1. Backend creates URL
2. Database record created
3. Browser starts upload
4. Upload failsIf you immediately mark the database record as:
UPLOADEDyou now have inconsistent state.
A better model is:
PENDING
│
│ upload succeeds
▼
UPLOADEDor:
PENDING
│
│ upload fails
▼
FAILEDYou can also design a verification step if your application needs stronger guarantees.
19. Validate more than the extension
This is not enough:
if (file.name.endsWith(".pdf")) {
// safe
}A filename extension is not a security boundary.
Depending on your application, you may need additional validation such as:
- declared MIME type
- file size
- actual file format
- malware scanning
- image processing
- PDF validation
- content inspection
For example:
Upload
↓
S3
↓
Validation / scanning
↓
Approved?
/ \
yes no
| |
▼ ▼
Use Quarantine/DeleteFor sensitive applications, treat uploaded files as untrusted input.
20. Don't give the browser permanent AWS credentials
This is one of the biggest benefits of the architecture.
Avoid:
Browser
│
├── AWS_ACCESS_KEY_ID
└── AWS_SECRET_ACCESS_KEYInstead:
Browser
│
│ authenticated request
▼
Backend
│
│ creates temporary permission
▼
Presigned URL
│
▼
S3The browser gets access to the specific operation represented by the presigned request rather than your application's long-lived AWS credentials.
AWS explicitly describes presigned URLs as a way to allow uploads without requiring the uploading party to have AWS credentials.
21. What should the backend control?
A production upload endpoint should think about at least these questions:
| Question | Example |
|---|---|
| Who is uploading? | Authenticated user |
| What can they upload? | PDF/images |
| Maximum size? | Feature-specific limit |
| Where does it go? | User-specific S3 prefix |
| How long is the URL valid? | Short lifetime |
| Can the object overwrite another? | Usually avoid accidental collisions |
| Does the file need scanning? | Depends on application |
| Who can download it? | Owner/admin/application |
| Should metadata be stored? | Usually yes |
This is much more useful than thinking only:
"How do I upload a file to S3?"
The real engineering problem is:
"How do I design a controlled file lifecycle?"
22. Upload versus download
Presigned URLs are useful in both directions.
Upload
Backend
│
│ signed PUT URL
▼
Browser
│
│ PUT
▼
S3Download
Backend
│
│ signed GET URL
▼
Browser
│
│ GET
▼
S3The HTTP operation changes:
PUT → upload
GET → downloadAWS supports presigned URLs for both upload and download scenarios.
23. When this architecture is a great fit
Presigned uploads work particularly well for:
- profile pictures
- company logos
- resumes
- PDFs
- documents
- screenshots
- user-generated media
- static assets
- large browser uploads
For example, a job platform might use:
Company Logo
↓
S3
Resume
↓
S3
Job Banner
↓
S3
Application Document
↓
S3The application server handles authorization and metadata instead of transferring every byte itself.
24. When you may need something more advanced
For very large uploads, a simple single PUT may not be the ideal solution.
You may eventually consider:
Multipart Uploadinstead of:
Single PUTA multipart strategy can split a large object into multiple parts.
Conceptually:
Large File
│
├── Part 1
├── Part 2
├── Part 3
├── Part 4
└── Part 5
│
▼
Amazon S3
│
▼
Complete objectAWS provides multipart-upload support for S3, and the AWS SDK for JavaScript v3 also provides tooling for multipart uploads.
For a normal profile image or resume upload, you probably don't need to start there.
25. Troubleshooting checklist
If your upload doesn't work, check these in order.
403 Forbidden
Check:
IAM permissions
Bucket permissions
Presigned URL
CORS
ExpirationA browser cross-origin request can fail when the S3 CORS configuration does not allow the origin, method, or headers being used.
SignatureDoesNotMatch
Check:
Correct AWS region
Correct URL
URL not modified
URL not expired
Content-Type matches
System clockThese are specifically called out in AWS's presigned-upload troubleshooting guidance.
CORS error in browser
Check your bucket's CORS configuration.
For example:
[
{
"AllowedOrigins": [
"https://karyvio.com"
],
"AllowedMethods": [
"PUT"
],
"AllowedHeaders": [
"*"
]
}
]Also remember that CORS configuration does not grant permissions by itself. IAM and bucket policies still apply.
Upload works with curl but not browser
This often points toward a browser-specific issue such as CORS or request headers.
Test the signed URL separately:
curl -X PUT \
-H "Content-Type: application/pdf" \
--upload-file ./resume.pdf \
"YOUR_PRESIGNED_URL"AWS recommends using the same content type that was used when generating the signed URL.
26. A production-minded upload flow
A more complete architecture looks like this:
┌─────────────────┐
│ Browser │
└────────┬────────┘
│
│ 1. Upload request
▼
┌─────────────────┐
│ API Server │
└────────┬────────┘
│
┌─────────┴─────────┐
│ │
▼ ▼
Authenticate Validate
│ │
└─────────┬─────────┘
│
│ 2. Sign request
▼
┌─────────────────┐
│ Presigned URL │
└────────┬────────┘
│
│ 3. PUT
▼
┌─────────────────┐
│ Amazon S3 │
└────────┬────────┘
│
│ 4. Record metadata
▼
┌─────────────────┐
│ Database │
└─────────────────┘This architecture keeps responsibilities clear.
27. The key lesson
The biggest lesson isn't actually the AWS SDK code.
It's the architecture.
Instead of making your backend responsible for every byte:
Browser
↓
Backend
↓
S3you can make your backend responsible for permission:
Browser
↓
Backend
│
└── permission
↓
Browser
↓
S3That means:
Backend
= authorization + validation + metadata
S3
= object storage
Browser
= direct file transferOnce you understand that separation, presigned URLs become much easier to reason about.
Practical takeaway
If you're building a modern web application that accepts files, a useful starting architecture is:
1. Authenticate the user
2. Validate the requested file type
3. Validate application-specific limits
4. Generate a unique S3 object key
5. Generate a short-lived presigned URL
6. Upload directly from the browser
7. Store the S3 key and metadata in your database
8. Treat uploaded files as untrusted input
9. Add scanning/processing when the application requires it
10. Use multipart uploads when your file sizes and requirements justify themThe result is a cleaner separation between your application server and object storage.
And more importantly, you now have a pattern you can reuse for profiles, resumes, company assets, documents, media, and many other real-world features.
The architecture to remember
AUTHENTICATED USER
│
▼
┌───────────────┐
│ Node.js API │
│ │
│ Auth │
│ Validate │
│ Sign │
└───────┬───────┘
│
Presigned URL
│
▼
┌───────────────┐
│ Browser │
│ │
│ PUT file │
└───────┬───────┘
│
▼
┌───────────────┐
│ Amazon S3 │
│ │
│ Object Storage│
└───────────────┘Authorize with your application. Store the file in S3. Let the browser move the data directly.
Official AWS references
- Amazon S3 presigned URLs — upload and download
- Amazon S3 presigned URL upload documentation
- Amazon S3 CORS documentation
- AWS SDK for JavaScript v3 S3 examples
https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html
https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html
https://docs.aws.amazon.com/AmazonS3/latest/userguide/cors.html






Comments (1)
Really helpful article! The explanation of AWS S3 presigned URLs and secure file-upload practices was clear and practical. The security considerations were especially useful.