How to Build a Secure File Upload API With Node.js and S3
File uploads break production systems in ways that feel embarrassing in retrospect. An unvalidated MIME type lets an attacker store a PHP shell. A flat S3 bucket leaks one tenant's documents to another. A missing size cap lets a single request drain your storage budget overnight. Most tutorials walk you through multer and putObject and call it done. This one does not.
Here is a production-grade blueprint for file upload in a Node.js SaaS, covering every layer that actually matters.
Why Direct-Upload-to-Server Is the Wrong Default
The naive pattern — client sends file to your API server, server streams it to S3 — means every byte of every upload passes through your application layer. That burns CPU, memory, and bandwidth you are already paying AWS for. At scale, a burst of large uploads will spike your instance memory and trigger restarts.
The better default is presigned URLs: your server generates a short-lived, scoped S3 URL; the client uploads directly to S3; your server is never in the data path.
Step 1: Generate a Presigned Upload URL
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { v4 as uuidv4 } from "uuid";
const s3 = new S3Client({ region: process.env.AWS_REGION });
export async function generateUploadUrl({ tenantId, fileName, contentType }) {
const key = `tenants/${tenantId}/uploads/${uuidv4()}/${sanitizeFileName(fileName)}`;
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
ContentType: contentType,
// Enforce a 10 MB size limit at the S3 layer
ContentLengthRange: [1, 10 * 1024 * 1024],
});
const url = await getSignedUrl(s3, command, { expiresIn: 300 }); // 5 minutes
return { url, key };
}
A few things worth calling out here. The key embeds the tenantId as a path prefix — that is the foundation of per-tenant isolation, covered below. The UUID in the path prevents filename collisions and makes enumeration attacks harder. The five-minute expiry window is short enough to be meaningless to an attacker but long enough for any realistic client-side flow.
Step 2: Validate File Type — On the Server, Not the Client
Never trust Content-Type headers or file extensions alone. Both are trivially spoofed. A file named invoice.pdf with Content-Type: application/pdf can contain anything.
After the client uploads, trigger a server-side validation step using magic byte inspection. The file-type npm package reads the first few bytes of the file from S3 and returns the real MIME type.
import { fileTypeFromStream } from "file-type";
import { GetObjectCommand } from "@aws-sdk/client-s3";
const ALLOWED_TYPES = new Set(["image/jpeg", "image/png", "application/pdf"]);
export async function validateFileType(key) {
const { Body } = await s3.send(new GetObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
}));
const type = await fileTypeFromStream(Body);
if (!type || !ALLOWED_TYPES.has(type.mime)) {
// Move or delete the file immediately
await quarantineFile(key);
throw new Error(`Rejected file type: ${type?.mime ?? "unknown"}`);
}
return type.mime;
}
Run this validation in a Lambda or a background worker triggered by an S3 ObjectCreated event. This keeps your API response fast and your validation asynchronous.
Step 3: Add a Virus Scanning Hook
You do not need to build a scanner. You need a hook point. The simplest production pattern is to route every newly uploaded object through ClamAV running in a Lambda, triggered by the same ObjectCreated event.
The flow looks like this:
- File lands in
s3://your-bucket/tenants/{id}/uploads/staging/ - S3 event triggers a Lambda that runs a ClamAV scan
- Clean files are moved to
tenants/{id}/uploads/verified/ - Infected files are moved to
tenants/{id}/uploads/quarantine/and your team is alerted
Your application only ever serves files from the verified/ prefix. Anything in staging/ is invisible to end users. This is a simple prefix-gate pattern — no complex state machine required.
Third-party options like BucketAV (formerly VirusTotal S3) or Cloud Storage Security can replace the DIY Lambda if you want managed scanning without the operational overhead.
Step 4: Per-Tenant Storage Isolation
Multi-tenant SaaS applications need hard guarantees that tenant A cannot access tenant B's files. S3 does not enforce this for you by default — your application logic does.
The Prefix Pattern
Every key is namespaced: tenants/{tenantId}/.... Every presigned URL is generated by a backend function that pulls tenantId from the authenticated session, never from user input.
IAM Scope-Down Policies
When generating presigned URLs, you can attach a scope-down policy to restrict what the credentials can touch:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject"],
"Resource": "arn:aws:s3:::your-bucket/tenants/${tenantId}/*"
}]
}
Even if a presigned URL were somehow leaked or reused, it is scoped to a single tenant's prefix.
Download URL Generation
Never expose raw S3 object URLs to clients. Generate a fresh presigned GetObject URL for every download request — after verifying the requesting user belongs to the tenant that owns the file. A short expiry (60–300 seconds) means a shared or leaked link becomes useless almost immediately.
Step 5: Metadata Tracking in Your Database
S3 is object storage, not a queryable data layer. Every uploaded file should have a corresponding row in your database:
| Column | Purpose |
|---|---|
id | Your internal file ID |
tenant_id | FK to tenants table |
s3_key | Full S3 object key |
original_name | User-facing display name |
mime_type | Verified MIME, not user-supplied |
size_bytes | Actual size after upload |
scan_status | pending, clean, infected |
uploaded_by | User FK |
created_at | Timestamp |
This table is the source of truth. You never query S3 to list a user's files — you query this table and generate presigned URLs on demand. It also gives you a clean audit trail, which enterprise customers will ask for.
Common Mistakes to Avoid
- Storing user-supplied filenames directly as S3 keys. Path traversal and Unicode tricks can cause subtle bugs or expose unintended objects.
- Relying on S3 bucket policies alone for tenant isolation. Policy complexity grows fast; prefix-scoped presigned URLs are simpler and auditable.
- Skipping the staging/verified split. Without it, an unscanned file can be served to users in the window between upload and scan completion.
- Setting presigned URL expiry too long. An hour-long URL sent over email is effectively a public link for that duration.
Why This Matters for Your Project
File handling is one of those features that looks trivial in a demo and becomes a liability the moment real users — and real attackers — show up. If you are building a SaaS product that handles documents, images, or any user-generated content, the patterns above are not premature optimisation. They are the floor. Getting the architecture right early — presigned URLs, validated types, scanned content, isolated prefixes, database-tracked metadata — means you are not retrofitting security controls into a live system under pressure. That is a significantly worse problem to have.





