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:

ColumnPurpose
idYour internal file ID
tenant_idFK to tenants table
s3_keyFull S3 object key
original_nameUser-facing display name
mime_typeVerified MIME, not user-supplied
size_bytesActual size after upload
scan_statuspending, clean, infected
uploaded_byUser FK
created_atTimestamp

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.