Back to Blog

File Upload Architecture for SaaS — Direct S3 Upload With Presigned URLs in NestJS

Published: 2026-06-23
File Upload Architecture for SaaS — Direct S3 Upload With Presigned URLs in NestJS

Most SaaS apps handle file uploads by receiving the file on their NestJS server, writing it to a temporary buffer, and then uploading it to S3 from there. The server pays the bandwidth cost twice — once receiving the file from the client, once sending it to S3. The request thread is blocked for the entire duration. And if the file is large enough, the process runs out of memory.

The fix is a pattern that has been well-documented for years and still gets implemented wrong in most projects we audit: presigned URLs. The server never touches the file. It generates a time-limited URL, hands it to the client, and the client uploads directly to S3. The server's only job is authentication, validation, and orchestration.

If your current file upload approach is a multipart form that streams through your NestJS controller — this post is for you.

SaaS file upload architecture — a USB drive beside a laptop representing direct file transfer to cloud storage

The Presigned URL File Upload Flow

The flow has three participants and four steps:

  1. Client requests an upload URL from the NestJS server, sending the file name, MIME type, and file size.
  2. NestJS server validates the request (auth, file type, size limits), generates a presigned URL using the AWS SDK, and returns it.
  3. Client uploads the file directly to S3 using the presigned URL via a standard HTTP PUT request. Progress tracking works through the browser's XMLHttpRequest or fetch with a ReadableStream.
  4. S3 triggers a notification (SQS, SNS, or Lambda) after the upload completes, which your backend processes — scanning for viruses, generating thumbnails, updating the database.

The server never sees the file bytes. That is the entire point.

NestJS S3 Service Setup

TypeScript
1// s3.service.ts
2import { Injectable } from "@nestjs/common"
3import { ConfigService } from "@nestjs/config"
4import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"
5import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
6import { v4 as uuid } from "uuid"
7
8@Injectable()
9export class S3Service {
10  private readonly s3: S3Client
11  private readonly bucket: string
12
13  constructor(private config: ConfigService) {
14    this.s3 = new S3Client({
15      region: this.config.get("AWS_REGION"),
16      credentials: {
17        accessKeyId: this.config.get("AWS_ACCESS_KEY_ID"),
18        secretAccessKey: this.config.get("AWS_SECRET_ACCESS_KEY"),
19      },
20    })
21    this.bucket = this.config.get("AWS_S3_BUCKET")
22  }
23
24  async generateUploadUrl(
25    tenantId: string,
26    fileName: string,
27    contentType: string,
28    fileSize: number
29  ): Promise<{ url: string; key: string }> {
30    const key = `uploads/${tenantId}/${uuid()}/${fileName}`
31
32    const command = new PutObjectCommand({
33      Bucket: this.bucket,
34      Key: key,
35      ContentType: contentType,
36      ContentLength: fileSize,
37    })
38
39    const url = await getSignedUrl(this.s3, command, { expiresIn: 900 })
40
41    return { url, key }
42  }
43}

The key path includes the tenant ID and a UUID to prevent name collisions. The presigned URL expires in 15 minutes — long enough for most uploads, short enough that a leaked URL is not useful for long.

The ContentLength parameter tells S3 to reject uploads that do not match the declared size. This is your first line of defence against files that are larger than expected.

File Type Validation on Server and Client

Never trust the client's MIME type alone. A user can rename malware.exe to photo.jpg in the browser's file dialog and the MIME type field will say image/jpeg.

TypeScript
1// upload.controller.ts
2import { Controller, Post, Body, Req, UseGuards, BadRequestException } from "@nestjs/common"
3import { JwtAuthGuard } from "../auth/jwt-auth.guard"
4import { S3Service } from "./s3.service"
5
6const ALLOWED_MIME_TYPES = [
7  "image/jpeg",
8  "image/png",
9  "image/webp",
10  "application/pdf",
11  "text/csv",
12]
13
14const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50MB
15
16@Controller("upload")
17export class UploadController {
18  constructor(private readonly s3: S3Service) {}
19
20  @Post("initiate")
21  @UseGuards(JwtAuthGuard)
22  async initiateUpload(
23    @Body() body: { fileName: string; contentType: string; fileSize: number },
24    @Req() req,
25  ) {
26    if (!ALLOWED_MIME_TYPES.includes(body.contentType)) {
27      throw new BadRequestException("File type not supported")
28    }
29
30    if (body.fileSize > MAX_FILE_SIZE) {
31      throw new BadRequestException("File exceeds maximum size")
32    }
33
34    const extension = body.fileName.split(".").pop()?.toLowerCase()
35    const allowedExtensions = ["jpg", "jpeg", "png", "webp", "pdf", "csv"]
36    if (!extension || !allowedExtensions.includes(extension)) {
37      throw new BadRequestException("File extension not supported")
38    }
39
40    // tenantId comes from the authenticated session, never the request body
41    return this.s3.generateUploadUrl(
42      req.user.tenantId,
43      body.fileName,
44      body.contentType,
45      body.fileSize
46    )
47  }
48}

On the client side, validate before the upload request even leaves the browser:

TypeScript
1function validateFile(file: File): string | null {
2  const allowed = ["image/jpeg", "image/png", "image/webp", "application/pdf", "text/csv"]
3  if (!allowed.includes(file.type)) return "File type is not supported"
4  if (file.size > 50 * 1024 * 1024) return "File exceeds 50MB limit"
5  return null
6}

Client-side validation is a UX feature, not a security boundary. It saves the user a round trip when they accidentally select the wrong file. The server-side check is the real enforcement.

Frontend: Upload Directly to S3 With Progress

TypeScript
1"use client"
2
3import { useState } from "react"
4
5export function useFileUpload() {
6  const [progress, setProgress] = useState(0)
7  const [uploading, setUploading] = useState(false)
8
9  const upload = async (file: File) => {
10    setUploading(true)
11    setProgress(0)
12
13    const res = await fetch("/api/upload/initiate", {
14      method: "POST",
15      headers: { "Content-Type": "application/json" },
16      body: JSON.stringify({
17        fileName: file.name,
18        contentType: file.type,
19        fileSize: file.size,
20      }),
21    })
22
23    if (!res.ok) throw new Error("Failed to get upload URL")
24
25    const { url, key } = await res.json()
26
27    await new Promise<void>((resolve, reject) => {
28      const xhr = new XMLHttpRequest()
29      xhr.open("PUT", url)
30      xhr.setRequestHeader("Content-Type", file.type)
31
32      xhr.upload.onprogress = (e) => {
33        if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100))
34      }
35
36      xhr.onload = () => {
37        if (xhr.status === 200) resolve()
38        else reject(new Error(`Upload failed: ${xhr.status}`))
39      }
40
41      xhr.onerror = () => reject(new Error("Network error during upload"))
42      xhr.send(file)
43    })
44
45    setUploading(false)
46    return key
47  }
48
49  return { upload, progress, uploading }
50}

XMLHttpRequest is used over fetch here because fetch still does not support upload progress tracking natively across all browsers. If you need progress bars — and users expect progress bars for file uploads — XHR is the practical choice.

File Size Limits at the Presigned URL Level

The ContentLength parameter in the presigned URL command tells S3 to reject uploads that do not match. But this is a client-declared size. If a malicious client requests a URL for a 1MB file and uploads a 500MB payload, the ContentLength check in PutObjectCommand does not block it — that parameter only sets a condition for the presigned URL.

The real enforcement is in the S3 bucket policy or a custom condition in the presigned URL:

TypeScript
1import { PutObjectCommand } from "@aws-sdk/client-s3"
2
3// Method on S3Service (uses the injected this.s3 client)
4async generateUrlWithSizeLimit(
5  fileName: string,
6  contentType: string,
7  declaredSize: number
8) {
9  const maxSize = 50 * 1024 * 1024
10
11  const command = new PutObjectCommand({
12    Bucket: process.env.AWS_S3_BUCKET,
13    Key: `uploads/${uuid()}/${fileName}`,
14    ContentType: contentType,
15    Metadata: {
16      "max-size": maxSize.toString(),
17    },
18  })
19
20  const url = await getSignedUrl(this.s3, command, { expiresIn: 900 })
21  return { url, key: command.input.Key }
22}

For strict enforcement, add a bucket policy that rejects objects larger than your limit, or use a Lambda function triggered by s3:ObjectCreated: that deletes oversized files and alerts your team. The presigned URL is a gate, not a lock.

S3 server rack infrastructure for scalable file storage and upload handling

Post-Upload Webhook Notification

After the file lands in S3, your backend needs to know about it. The cleanest approach is S3 Event Notifications to an SQS queue, which your NestJS app polls:

TypeScript
1// s3-event.module.ts
2import { Module } from "@nestjs/common"
3import { SqsModule } from "@ssut/nestjs-sqs"
4
5@Module({
6  imports: [
7    SqsModule.register({
8      consumers: [
9        {
10          name: "file-uploaded-queue",
11          queueUrl: process.env.S3_EVENT_QUEUE_URL,
12          region: process.env.AWS_REGION,
13        },
14      ],
15    }),
16  ],
17})
18export class S3EventModule {}
TypeScript
1// file-uploaded.consumer.ts
2import { SqsMessageHandler } from "@ssut/nestjs-sqs"
3import { Injectable } from "@nestjs/common"
4
5interface S3EventRecord {
6  s3: {
7    bucket: { name: string }
8    object: { key: string; size: number }
9  }
10}
11
12@Injectable()
13export class FileUploadedConsumer {
14  @SqsMessageHandler("file-uploaded-queue", false)
15  async handle(message: { Body: string }) {
16    const event = JSON.parse(message.Body) as { Records: S3EventRecord[] }
17
18    for (const record of event.Records) {
19      const key = record.s3.object.key
20      const size = record.s3.object.size
21
22      // Update database with file metadata
23      // Trigger virus scan
24      // Generate thumbnail if image
25      // Notify relevant users
26    }
27  }
28}

Configure the S3 bucket to send s3:ObjectCreated:* events to an SQS queue. Your NestJS consumer processes each event, updates the database with the file record, and triggers downstream tasks like virus scanning and thumbnail generation.

Virus Scanning With ClamAV

Never serve user-uploaded files to other users before scanning them. The standard approach is a Lambda function triggered by the S3 event, running ClamAV:

TypeScript
1// scan.service.ts
2import { Injectable } from "@nestjs/common"
3import { S3 } from "@aws-sdk/client-s3"
4import { spawn } from "child_process"
5import { Readable } from "stream"
6
7@Injectable()
8export class ScanService {
9  private readonly s3 = new S3({ region: process.env.AWS_REGION })
10
11  async scanFile(bucket: string, key: string): Promise<"clean" | "infected"> {
12    const { Body } = await this.s3.getObject({ Bucket: bucket, Key: key })
13
14    // Stream file to ClamAV
15    const clamscan = spawn("clamscan", ["--stdin", "--quiet"])
16
17    return new Promise((resolve) => {
18      if (Body instanceof Readable) {
19        Body.pipe(clamscan.stdin)
20      }
21
22      clamscan.on("close", (code) => {
23        if (code === 0) {
24          resolve("clean")
25        } else {
26          resolve("infected")
27        }
28      })
29    })
30  }
31}

For production, use a dedicated Lambda function with the ClamAV layer rather than running ClamAV inside your NestJS process. The cdk-serverless-clamscan project from AWS Labs packages ClamAV with the Lambda runtime. Your NestJS app triggers the scan by sending an SQS message; the Lambda function downloads the file from S3, scans it, and updates a DynamoDB table or your database with the result.

If a file is infected, move it to a quarantine/ prefix in S3 and notify the uploader that their file was rejected. Never serve infected files under any circumstances.

Image Processing and Thumbnail Generation

After the virus scan passes, generate thumbnails for image uploads. Use Sharp in a worker process:

TypeScript
1// thumbnail.service.ts
2import { Injectable } from "@nestjs/common"
3import { S3 } from "@aws-sdk/client-s3"
4import sharp from "sharp"
5
6@Injectable()
7export class ThumbnailService {
8  private readonly s3 = new S3({ region: process.env.AWS_REGION })
9
10  async generateThumbnail(
11    bucket: string,
12    key: string,
13    sizes: number[] = [150, 300, 600]
14  ): Promise<string[]> {
15    const { Body } = await this.s3.getObject({ Bucket: bucket, Key: key })
16    const buffer = await Body.transformToByteArray()
17    const basePath = key.replace(/\.[^.]+$/, "")
18    const results: string[] = []
19
20    for (const size of sizes) {
21      const thumbKey = `${basePath}-thumb-${size}.webp`
22
23      const resized = await sharp(buffer)
24        .resize(size, undefined, { withoutEnlargement: true })
25        .webp({ quality: 80 })
26        .toBuffer()
27
28      await this.s3.putObject({
29        Bucket: bucket,
30        Key: thumbKey,
31        Body: resized,
32        ContentType: "image/webp",
33      })
34
35      results.push(thumbKey)
36    }
37
38    return results
39  }
40}

Do not run thumbnail generation synchronously in the upload request path. The post-upload webhook consumer should enqueue a thumbnail job to a BullMQ queue, and a separate worker processes it. If the thumbnail generation fails, the job retries without blocking the user.

Store the generated thumbnail keys alongside the original file key in your database so the frontend knows which sizes are available:

TypeScript
1await prisma.file.create({
2  data: {
3    originalKey: key,
4    thumbnailKeys: results,
5    mimeType: "image/webp",
6    fileSize: buffer.length,
7    tenantId,
8  },
9})

Organizing Files by Tenant

Every uploaded file should include the tenant ID in its S3 key path. Use a consistent prefix structure:

uploads/{tenantId}/{entityType}/{uuid}-{timestamp}.{ext}
TypeScript
1export function buildKey(
2  tenantId: string,
3  entityType: "avatar" | "document" | "attachment",
4  fileName: string
5): string {
6  const ext = fileName.split(".").pop()
7  const id = uuid()
8  const timestamp = Date.now()
9  return `uploads/${tenantId}/${entityType}/${id}-${timestamp}.${ext}`
10}

This structure gives you:

  • Isolation — S3 bucket policies can restrict access by prefix per tenant.
  • Lifecycle management — set expiration policies on temp-upload prefixes.
  • Audit — the key path tells you exactly which tenant and feature the file belongs to without querying a database.

Set S3 lifecycle rules to move files older than 90 days to Glacier, and delete files that have been soft-deleted in your application after 30 days. Storage costs accumulate silently when orphaned files are never cleaned up.

Cyber security virus scanning concept — tiles spelling SECURITY representing antivirus protection for uploaded files


Presigned URLs let you build a file upload system where the server validates, authenticates, and orchestrates — but never touches the file bytes. The pattern is well-established: your NestJS backend generates the URL, the client uploads directly to S3, and a post-upload pipeline handles scanning, thumbnails, and database indexing.

For more on the event-driven consumer pattern used in the post-upload pipeline, see our guide on event-driven architecture in NestJS. And for the authentication layer that protects your upload endpoints, our API key authentication guide covers the guard implementation.

The AWS presigned URL documentation covers the full API reference. The Sharp image processing library handles the thumbnail generation.

Every SaaS eventually needs to handle file uploads. The difference between a system that works and one that falls over under load is whether the server touches the file or not. Do not let your application server be a file pipe. Generate the URL, hand it over, and get back to doing what your server is actually for — running business logic.

Frequently Asked Questions

An S3 presigned URL is a time-limited URL that grants temporary access to perform a specific action on an S3 object — like upload or download. Your backend generates it using AWS credentials and signs the URL with an expiration time. The client uses this URL to upload directly to S3 without needing any AWS credentials, keeping your access keys secure.

Uploading through your server ties up request threads during file transfer, doubles bandwidth costs (server receives then sends to S3), and creates a memory bottleneck for large files. Presigned URLs let the client upload directly to S3, eliminating server load entirely and reducing latency because S3 edge locations are closer to the user.

Use a two-layer approach. On the client side, check the file's MIME type before sending the upload request. On the server side, validate the MIME type and file extension when generating the presigned URL, and include a Content-Type condition in the presigned URL policy so S3 rejects mismatched types at upload time.

Use a post-upload webhook that S3 triggers via Lambda or SQS after an object is created. The Lambda function runs ClamAV or a third-party scanner, checks the file, and moves it to a quarantined prefix if infected or marks it clean in your database. This keeps virus scanning out of your request path.

Use a folder prefix strategy: uploads/{tenantId}/{entityType}/{uuid}/{filename}. NestJS generates the key path when creating the presigned URL. This keeps each tenant's files isolated in S3, makes lifecycle policies per-prefix straightforward, and prevents accidental cross-tenant data access.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio