Skip to content

Handling File Uploads Securely

Aman Kumar Singh8 min read
Part 22 of 40From the Full Stack SaaS Masterclass series
Handling File Uploads Securely — article by Aman Kumar Singh

In Adding Full-Text Search, we let users search across their own data. File uploads are the natural next feature request. They're also the point where a SaaS quietly opens itself up to some of its worst vulnerabilities, if you're not careful.

This is part of the Full Stack SaaS Masterclass, a build-it-for-real series where we take a multi-tenant SaaS from an empty folder to production. By now the app has auth, tenants, and search. Users are about to start attaching invoices, avatars, and CSV imports. Every one of those uploads is a request from the outside world that your server didn't fully control.

I've seen upload features shipped as an afterthought more often than any other part of a SaaS, probably because "let the user pick a file and store it somewhere" sounds simple. It is simple, right up until someone uploads a file named ../../etc/passwd, or a 4 GB video that pins your event loop, or an SVG with an embedded script that runs in another tenant's browser. This article covers the parts that actually matter: where files live, how you validate them, and how tenants stay isolated from each other's data.

Never store uploads on the app server's disk

The first decision, and the one that prevents the most pain later, is where files physically live. Don't write uploaded files to the local disk of your NestJS server.

It feels convenient in development. Then you deploy two instances behind a load balancer and a user's avatar exists on one server but not the other. Or you redeploy and the ephemeral filesystem wipes everything. Or your disk fills up because nobody set a retention policy. Object storage exists specifically to remove this class of problem, so use it from day one, even for a single-tenant MVP.

For an AWS-based stack, that means S3. The pattern I reach for is direct-to-S3 uploads using presigned URLs, rather than routing the file bytes through your Node process at all.

// upload.service.ts
import { Injectable } from '@nestjs/common';
import {
  S3Client,
  PutObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'crypto';

const ALLOWED_MIME_TYPES = new Set([
  'image/png',
  'image/jpeg',
  'application/pdf',
]);

const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10 MB

@Injectable()
export class UploadService {
  private readonly s3 = new S3Client({ region: process.env.AWS_REGION });

  async createPresignedUpload(params: {
    organizationId: string;
    fileName: string;
    mimeType: string;
    sizeBytes: number;
  }) {
    if (!ALLOWED_MIME_TYPES.has(params.mimeType)) {
      throw new Error(`Unsupported file type: ${params.mimeType}`);
    }

    if (params.sizeBytes > MAX_UPLOAD_BYTES) {
      throw new Error('File exceeds maximum allowed size');
    }

    // Tenant-scoped, unguessable key. Never trust the client's filename in the path.
    const extension = params.mimeType === 'application/pdf' ? 'pdf' : 'img';
    const objectKey = `org-${params.organizationId}/${randomUUID()}.${extension}`;

    const command = new PutObjectCommand({
      Bucket: process.env.UPLOADS_BUCKET,
      Key: objectKey,
      ContentType: params.mimeType,
      ContentLength: params.sizeBytes,
    });

    const uploadUrl = await getSignedUrl(this.s3, command, { expiresIn: 300 });

    return { uploadUrl, objectKey };
  }
}

The client asks your API for a presigned URL, then uploads the file bytes straight to S3. Your Node process never touches the raw payload, which means a slow or malicious upload can't tie up your API's memory or event loop. The tradeoff is that validation you'd normally do while streaming the file (virus scanning, deep content inspection) has to happen after the fact, which we'll get to below.

Validate on the server, not just in the browser

A file input with accept="image/png,image/jpeg" is a UX hint, not a security control. Anyone can send a request directly to your API with any content type and any bytes. Every check that matters has to happen server-side, and MIME type from the client is itself untrustworthy since it's just a header the caller sets.

The safer approach is to check the actual file signature (magic bytes) rather than trusting Content-Type. Libraries like file-type read the first few bytes of a buffer and tell you what it actually is.

// file-validation.util.ts
import { fileTypeFromBuffer } from 'file-type';

const ALLOWED_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'pdf']);

export async function assertRealFileType(buffer: Buffer): Promise<string> {
  const detected = await fileTypeFromBuffer(buffer);

  if (!detected || !ALLOWED_EXTENSIONS.has(detected.ext)) {
    throw new Error('File content does not match an allowed type');
  }

  return detected.ext;
}

If you're using presigned URLs, you can't run this check before the upload completes, since the bytes go straight to S3. Instead, wire an S3 event notification (or a Lambda trigger) to fire after the object lands, download it, run this check, and either tag the object as verified or delete it and mark the record failed in Postgres. It's an extra hop. But it keeps the validation logic out of your main API and lets it run asynchronously, without blocking the user's request.

Filenames deserve the same suspicion. Never use a client-supplied filename as a storage path or pass it unsanitized to a shell command, a template, or a database query built with string concatenation. Generate your own key (as in the example above) and store the original filename as metadata only, for display purposes.

Malware scanning and the SVG problem

Two upload-specific risks deserve their own callout because they don't fit neatly into "validate the MIME type."

The first is malware. A PDF or Office document can carry an embedded payload that a simple type check won't catch, since the file genuinely is a valid PDF, it just also contains something malicious. If your SaaS accepts documents that get downloaded and opened by other users (think shared attachments, not just personal avatars), scanning matters. ClamAV is the open-source standard here, and you can run it as a sidecar container or a Lambda layer that scans objects on the same S3 event trigger you're already using for type validation. Whether this is worth the operational overhead depends on what you're accepting: a private avatar upload has a very different risk profile than a shared file that gets forwarded around an organization.

The second is SVG. It looks like an image, but an SVG file is XML and can contain inline <script> tags. If you accept SVG uploads and later render them directly in a browser, whether as an <img> src that a browser decides to inline, or worse, embedded in the DOM, you've built a stored XSS vector into your file upload feature. If you don't have a specific need for user-uploaded SVGs, just exclude them from your allowed types. If you do need them, sanitize on upload with a library like DOMPurify configured for SVG, and always serve them with a Content-Disposition: attachment header or from a separate, cookie-less domain so a malicious script can't run in the context of your app.

Tenant isolation for stored files

Everything about multi-tenancy that applies to your database rows applies to files too. It's easy to forget, because storage feels separate from your main data model.

The object key in the S3 example above is prefixed with the organization ID, which is a start, but the prefix alone doesn't enforce anything. What enforces it is that every read path checks tenant ownership before generating a download URL. Store file metadata in Postgres alongside the object key, and always join through the organization the requesting user belongs to.

create table uploaded_files (
  id uuid primary key default gen_random_uuid(),
  organization_id uuid not null references organizations(id),
  uploaded_by uuid not null references users(id),
  object_key text not null unique,
  original_filename text not null,
  mime_type text not null,
  size_bytes integer not null,
  scan_status text not null default 'pending',
  created_at timestamptz not null default now()
);

create index idx_uploaded_files_org on uploaded_files (organization_id);

When a user requests a download, generate a presigned GET URL scoped to that specific object, after confirming the organization_id on the row matches the caller's tenant. Never expose a bucket that's publicly readable by object key alone. Presigned URLs with short expirations (a few minutes) are the right default, even for files that feel low-risk, because a leaked long-lived URL is a data breach waiting to happen. Making a bucket public for convenience is tempting, especially for something like avatars. It's still a mistake: public buckets remove your ability to audit or revoke access later, and a single misconfigured bucket policy can leak every tenant's files at once.

Size, rate, and abuse limits

The last category of pitfalls has nothing to do with malicious content. It comes down to resource exhaustion. A well-meaning user with a bad internet connection uploading a 2 GB video, or a script hitting your presigned-URL endpoint in a loop, can degrade the service for everyone else just as effectively as an attacker.

Enforce size limits in three places, not one: the client (for fast feedback), the presigned URL's ContentLength condition (S3 will reject anything over that), and your database or business logic (in case someone bypasses the client entirely). Rate-limit the endpoint that issues presigned URLs the same way you'd rate-limit any other write endpoint, since generating URLs is cheap for you but each one is an invitation for S3 traffic. Redis-backed rate limiting, which by this point in the series you likely already have wired up for other endpoints, works fine here too.

Set a lifecycle policy on your S3 bucket for anything that's meant to be temporary, like unconfirmed uploads that never got validated. It's a small thing, but storage costs and unbounded growth are the kind of problem that's invisible until a bill shows up.

Key takeaways

  • Store uploads in object storage like S3, never on the app server's local disk, and use presigned URLs so raw bytes don't pass through your Node process.
  • Validate file content by inspecting actual bytes (magic numbers), not the client-supplied MIME type or filename, and do it asynchronously via an event trigger when using direct-to-S3 uploads.
  • Exclude SVG uploads unless you specifically need them, since they can carry inline scripts; sanitize and isolate them if you do.
  • Scan for malware when accepted files are shared between users, not just held privately by the uploader.
  • Enforce tenant isolation on every read path with a database join, and always generate short-lived, scoped presigned URLs rather than public bucket access.
  • Enforce size and rate limits in multiple layers, since resource exhaustion is as real a threat as malicious content.

Frequently asked questions

Should I store file uploads in my database as blobs?

Generally no. Postgres can technically store binary data, but it bloats your database, complicates backups, and doesn't scale the way object storage does. Store the file in S3 (or equivalent) and keep only metadata (object key, size, MIME type, owner) in Postgres.

How do I validate a file's real type if the client can lie about the Content-Type header?

Read the actual bytes and check the file's magic number signature using a library like `file-type`, rather than trusting the header the client sent. This catches cases where someone renames a script to `photo.png`.

Do presigned URLs to S3 mean I skip server-side validation entirely?

No. Presigned URLs skip the step of your API handling the raw bytes during upload, but you still need to validate the object after it lands, typically via an S3 event notification that triggers a check (and deletes the object if it fails).

Is it safe to let users upload SVG files for avatars?

Only if you sanitize them, since SVG is XML and can contain inline scripts that execute if rendered directly in a browser. The simpler path for most SaaS products is to disallow SVG entirely and convert accepted images to PNG or JPEG server-side.

How short should a presigned download URL's expiration be?

A few minutes is a reasonable default for most SaaS use cases. Long enough for the browser to fetch the file, short enough that a leaked URL (in logs, browser history, or a shared link) doesn't stay valid indefinitely.

Do I need antivirus scanning for every file upload feature?

It depends on exposure. A private avatar that only the uploader sees is lower risk than a document shared across an organization or downloaded by other tenants. Scan when files move between users; it may be reasonable to skip it for low-risk, self-contained uploads.

Related articles

Security Best Practices — Aman Kumar Singh
Deploying a Full Stack SaaS — Aman Kumar Singh
Securing Your SaaS API — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.