What you'll learn
By the end of this you'll know how to use Amazon S3 from Node.js for real application file storage. You'll understand the S3 object model — buckets, objects, and keys — how to upload, download, and delete objects with SDK v3, how to generate presigned URLs so browsers can upload directly to S3 without routing through your server, how access control actually works, and how to avoid the most common mistakes that produce confusing AccessDenied and NoSuchKey errors.
Who this is for
- Developers who need to store user-uploaded files and want to understand how S3 works before integrating it
- Anyone who's used S3 before but isn't sure how presigned URLs work or when to use them
- Node.js developers who've seen
AccessDeniederrors from S3 and want to understand the access control model properly
You can skip this if you're already generating presigned URLs, configuring CORS on buckets, and handling S3 errors confidently. Come back if you run into an access control issue you can't explain or if you're adding a new upload pattern to an existing project.
What is Amazon S3?
S3 (Simple Storage Service) is AWS's object storage service. It stores arbitrary files — images, videos, PDFs, JSON exports, backups, build artifacts — as objects inside named containers called buckets. S3 is infinitely scalable, highly durable (11 nines, meaning 99.999999999% durability), and designed to be used via API.
Plain English: S3 is a file storage service in the cloud. You create a bucket (like a top-level folder), upload files into it, and retrieve them by their key (file path). Unlike a database, S3 doesn't understand queries — you store and retrieve by exact key or list by prefix.
Simple idea: a bucket is a namespace, a key is a path, and an object is the file plus its metadata. s3://my-app-uploads/avatars/user-123.jpg is a bucket called my-app-uploads, a key avatars/user-123.jpg.
Prerequisites
- AWS account with an IAM user that has S3 permissions
- Node.js 18 or higher
- AWS SDK v3 installed:
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY, andAWS_REGIONin your environment
Setup from zero
Step 1 — Create a bucket
- Open the AWS Console → S3 → Create bucket
- Name it globally unique (e.g.,
my-app-uploads-2026) — S3 bucket names are shared across all AWS accounts worldwide - Choose a region close to your server
- Leave Block all public access enabled unless you specifically need public objects
- Click Create bucket
Note: do not enable static website hosting unless you're serving a static site. For application file storage, private buckets with presigned URLs is the correct pattern.
Step 2 — Upload an object
// src/lib/s3.ts
import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
export const s3 = new S3Client({ region: process.env.AWS_REGION! });
const BUCKET = process.env.S3_BUCKET!;
export async function uploadBuffer(
key: string,
buffer: Buffer,
contentType: string
): Promise<string> {
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: buffer,
ContentType: contentType,
})
);
return key;
}
Step 3 — Download an object
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { Readable } from "stream";
export async function downloadAsBuffer(key: string): Promise<Buffer> {
const response = await s3.send(
new GetObjectCommand({ Bucket: BUCKET, Key: key })
);
const stream = response.Body as Readable;
const chunks: Uint8Array[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
> Little tip: S3's GetObjectCommand response body is a readable stream, not a buffer. You must consume the stream to get the data. If you forget to consume it and the connection sits idle long enough, the stream times out and your next S3 operation from the same client may hang. Always drain streams fully, even if you're discarding the content.
The mental model
S3 is not a filesystem. The slash in a key like avatars/user-123.jpg is just a character — there is no real folder called avatars. S3 lets you list by prefix, which gives the appearance of folders, but the underlying structure is a flat key-value store where the key is the full path string and the value is the object bytes.
Why this matters in practice:
- You cannot rename an object — you copy it to a new key and delete the old one
- You cannot move a folder — you have to copy every object matching the old prefix and delete the originals
- Listing objects under a prefix is fast; searching by content is not possible without external tooling
The two access control mechanisms you'll encounter most are bucket policies (IAM JSON documents attached to the bucket, granting or denying access to AWS accounts, roles, or the public) and presigned URLs (time-limited signed URLs that let a non-AWS party perform one specific operation, like uploading or downloading a specific object).
For application uploads, the right pattern is: your server generates a presigned upload URL → the browser uploads directly to S3 → your server records the key. This keeps large file bytes off your server entirely.
Key terms
Bucket — a globally named container. One AWS account can have up to 100 buckets by default (adjustable via support request). Choose a region for each bucket.
Object — a file stored in S3. Consists of a key (string), a body (up to 5 TB for multipart uploads), and metadata (Content-Type, custom headers, ETag, etc.).
Key — the full path string identifying an object within a bucket. Keys are unique within a bucket. Slashes in keys create the appearance of folders.
Presigned URL — a time-limited, request-signed URL that allows one specific S3 operation (GET or PUT) without AWS credentials. Used to let browsers upload directly or download private objects.
ETag — a hash of the object's content, returned by S3 on upload. Useful for verifying upload integrity and as a cache validator.
ACL (Access Control List) — an older per-object access control mechanism. Most new buckets disable ACLs in favor of bucket policies. Avoid ACLs on new work.
Step-by-step: presigned upload URL for browser uploads
This is the pattern for browser-side uploads. The browser asks your server for a presigned URL; your server generates it; the browser uploads directly to S3 without the file bytes touching your server.
npm install @aws-sdk/s3-request-presigner
// src/lib/presign.ts
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { s3 } from "./s3";
const BUCKET = process.env.S3_BUCKET!;
export async function getUploadUrl(
key: string,
contentType: string,
expiresIn = 300 // seconds
): Promise<string> {
return getSignedUrl(
s3,
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
ContentType: contentType,
}),
{ expiresIn }
);
}
Express route that issues the URL:
// server/routes/uploads.ts
import { randomUUID } from "crypto";
import { getUploadUrl } from "../lib/presign";
router.post("/upload-url", async (req, res) => {
const { contentType, filename } = req.body;
const ext = filename.split(".").pop() ?? "bin";
const key = `uploads/${randomUUID()}.${ext}`;
const url = await getUploadUrl(key, contentType);
res.json({ url, key });
});
Browser fetch to use it:
// Browser: get a URL then upload directly
const { url, key } = await (await fetch("/upload-url", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type, filename: file.name }),
})).json();
await fetch(url, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
// now save 'key' to your database
> Little tip: Use a generated UUID as part of the S3 key (uploads/${uuid}.jpg) rather than the user's original filename. Original filenames can contain spaces, special characters, and path traversal sequences. A UUID key is always safe, always unique, and never guessable by other users scanning for files.
Patterns
Presigned download URLs for private objects — instead of making objects public, generate a short-lived presigned GET URL when a user requests a file:
import { GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export async function getDownloadUrl(key: string): Promise<string> {
return getSignedUrl(s3, new GetObjectCommand({ Bucket: BUCKET, Key: key }), {
expiresIn: 3600, // 1 hour
});
}
Return the signed URL to the client in your API response. The user's browser fetches the file directly from S3 using the time-limited URL.
Delete on record removal — when a database record is deleted, also delete the corresponding S3 object:
export async function deleteObject(key: string): Promise<void> {
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }));
}
S3 does not automatically clean up objects when you delete a database row that referenced the key. Orphaned objects cost money and grow without bound.
Common mistakes
Making buckets public to serve files — unless you're running a static website or serving public CDN assets, never make a bucket or its objects public. Use presigned URLs to grant temporary, scoped access to specific objects instead.
Using the original filename as the S3 key — filenames from user uploads are untrusted input. Use a UUID plus the file extension. This also prevents collisions when two users upload files with the same name.
Not deleting S3 objects when records are removed — S3 storage costs accumulate. A bucket with millions of orphaned objects from deleted users costs real money each month. Always pair database record deletion with S3 object deletion.
Troubleshooting
AccessDenied on upload — the IAM user or role lacks s3:PutObject on the target bucket and key. Check the attached policies in IAM and confirm the Bucket and Key pattern in the policy's Resource field matches the key you're uploading to.
NoSuchBucket — the bucket name in your environment variable doesn't match an existing bucket, or the bucket is in a different region than the client is configured for. Buckets are region-specific; a us-east-1 client cannot access a eu-west-1 bucket without updating the client region.
NoSuchKey — the key you're requesting doesn't exist. Common causes: the upload didn't complete, the key was constructed differently during upload versus retrieval (check prefix and extension), or the object was deleted.
Checklist
- [ ] Bucket created in the same region as your server for lowest latency
- [ ] Block public access enabled on the bucket
- [ ] IAM policy grants
s3:PutObject,s3:GetObject,s3:DeleteObjecton the specific bucket ARN - [ ] S3 keys use generated UUIDs, never raw user-provided filenames
- [ ] Presigned URLs used for browser uploads and downloads, not public ACLs
- [ ] Presigned URL expiry set to the minimum necessary (300s for uploads, 3600s for downloads)
- [ ] S3 objects deleted when the database records that reference them are deleted
- [ ] Stream from
GetObjectCommandfully consumed before closing
Practice task
Build a file attachment feature for a simple notes app. The API has two routes: POST /upload-url that returns a presigned upload URL and a UUID key, and GET /notes/:id/attachment that looks up the note's S3 key in the database and returns a presigned download URL. The browser uploads directly to S3 using the upload URL and submits only the key to save in the note record. Test the full flow — upload a file, retrieve the download URL, confirm the file is accessible via the signed URL, then delete the note and confirm the S3 object is also removed.
FAQ
Should I serve S3 objects through CloudFront instead of directly from S3?
For files accessed frequently by many users — avatars, public images, documents — yes. CloudFront caches objects at edge locations globally, reduces latency, and reduces your S3 request costs. For files accessed rarely or per-user (private attachments), direct presigned URLs are simpler and sufficient.
What's the maximum upload size with a presigned URL?
A standard presigned PUT URL supports up to 5 GB per object. For larger files, use S3's multipart upload API, which splits the file into parts of 5 MB to 5 GB each. The @aws-sdk/lib-storage package provides a Upload utility that handles multipart automatically above a configurable threshold.
How do I add CORS to a bucket so browsers can upload directly?
Open the bucket in the AWS Console → Permissions → Cross-origin resource sharing (CORS) and add a JSON policy that allows PUT from your frontend domain. Without a CORS policy, the browser blocks the presigned PUT request before it reaches S3.
What to learn next
After S3 basics: CloudFront as a CDN in front of S3 for frequently accessed public assets, S3 lifecycle rules for automatic tiering and deletion of old objects, and multipart upload for large file support.
Related on Baseline
- AWS for Node apps — the SDK and credential setup that S3 integration depends on
- AWS Lambda basics — using Lambda triggers to process S3 objects on upload
- IAM least privilege — designing the S3 policy with exactly the permissions the application needs
Takeaways
S3 is a flat key-value object store, not a filesystem. Buckets hold objects; keys are path strings; there are no real folders. The two patterns you'll use most are server-side SDK uploads and presigned URLs for browser-direct uploads. Keep buckets private, use presigned URLs for access, and always clean up objects when database records are removed.
If you remember only one thing: use presigned URLs for browser uploads — never route file bytes through your server and never make buckets public to avoid auth. A presigned URL lets the browser talk directly to S3 with time-limited, scoped permission that your server controls completely.