Follow Us On Social Media Stay connected for daily tech jobs, interview prep & updates
Empowering developers and tech professionals with curated career guidance, tech job opportunities, and developer resources.
Stay in the loop Get our weekly digest of tech trends, career opportunities, and tools.
© 2026 Karyvio. All rights reserved.
Home / Cloud & DevOps / 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.
AWS S3 AWS SDK Node.js JavaScript REST API
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.
AWS S3 Presigned URLs: Build Secure File Uploads with Node.js | Karyvio 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:
The 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:
A user's filename should not determine your entire storage structure.
Instead, generate an application-controlled key.
For example:
users/1001/profile/8e4b2a1c.pngcompanies/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.
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:
For 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,
});
}means 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();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");
}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,
};
}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:
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:
AWS 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.
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:
you now have inconsistent state.
A better model is:
PENDING
│
│ upload succeeds
▼
UPLOADEDPENDING
│
│ 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_KEYBrowser
│
│ 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
▼
S3Backend
│
│ 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:
A 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:
you can make your backend responsible for permission :
Browser
↓
Backend
│
└── permission
↓
Browser
↓
S3Backend
= 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
Follow Us On Social Media Get Latest Update On Social Media
The Role As a Software Engineer I (Full Stack) on the App Components Team at Rippling, you will work on the "Enterprise Studio" experience.…
Bangalore, India Hybrid Full-timeRole Context and Expectations QAD, Inc. is hiring an Apprentice Engineer based in Mumbai, India. This entrylevel fulltime position is…
Mumbai, India
Role Overview The Software Intern position at Thales in Bengaluru places you inside an engineering competence centre focused on hardware,…
On-site
Keep reading Move a Node.js API to AWS Lambda: A Practical Serverless Migration Already have a Node.js API and wondering whether it can run without a traditional server? Learn how to move a small API endpoint to AWS Lambda, expose it through API Gateway, handle environment variables, structure the handler, and avoid common serverless mistakes.
npm Package Management: package.json, Dependencies, Scripts, and Lockfiles Learn how npm manages JavaScript project dependencies, scripts, versions, and lockfiles. Build a practical Node.js project and understand package.json, npm install, npm scripts, devDependencies, and package-lock.json.
React Fundamentals: Build Interactive UIs with Components, Props, State, and Hooks Learn the React fundamentals developers actually use, including components, JSX, props, state, events, lists, forms, Hooks, API integration, and reusable UI patterns.
Docker Commands Developers Actually Use: A Practical Guide Learn the Docker commands developers use most often to build images, run containers, inspect applications, manage images, view logs, and clean up unused resources.
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.