NestJS – File Upload

August 30, 20268 min readUpdated 9/5/2026

One fact decides everything about an upload endpoint: the Content-Type is whatever the client typed. So is the filename, and so is the extension. None of them are evidence about the bytes that arrived.

Nest handles multipart uploads through multer, wrapped in an interceptor. The wiring is four lines. The rest of this lesson is about what to do with the file once you have it, because that is the part with consequences.

The wiring

@Post('me/portfolio')
@UseInterceptors(
  FileInterceptor('file', {
    storage: memoryStorage(),
    limits: { fileSize: MAX_UPLOAD_BYTES, files: 1 },
  }),
)
addPortfolioImage(
  @CurrentUser() user: AuthenticatedUser,
  @UploadedFile() file: Express.Multer.File | undefined,
  @Body() dto: AddPortfolioImageDto,
) {
  return this.contractorsService.addPortfolioImage(user, file, dto.caption)
}

FileInterceptor('file', options) runs multer on the way in; the string is the multipart field name. @UploadedFile() is a parameter decorator that reads back what the interceptor put on the request — the same two-part pattern as lesson 9, applied by a library.

The siblings are FilesInterceptor for many files under one field name, FileFieldsInterceptor for several named fields, and AnyFilesInterceptor, which accepts whatever turns up and is rarely what you want.

Other fields in the same multipart body still reach @Body() — but note they arrive as strings, because multipart has no types. A DTO with a numeric field needs @Type(() => Number) to survive.

The parameter is typed | undefined on purpose. A request with no file at all is perfectly legal multipart, so the handler has to cope with it rather than assume.

memoryStorage is a security choice

Multer's default writes uploads to a temporary directory before any of your code runs. That is efficient, and it means a rejected upload has already landed on disk.

With memoryStorage(), nothing touches the disk until the bytes have been inspected and a filename generated. The trade is real — the whole file sits in RAM, which is fine at 5 MB per request and would not be for video.

Above roughly tens of megabytes the calculation flips, and the answer is usually neither: stream to object storage and keep the application out of the path entirely.

The limit that actually protects you

limits: { fileSize: MAX_UPLOAD_BYTES, files: 1 }

This is the one that matters, because multer stops reading at the limit and errors. A 2 GB upload costs 5 MB of memory rather than 2 GB.

The service checks the size again:

if (file.buffer.length > uploads.maxBytes) {
  const mb = Math.round(uploads.maxBytes / (1024 * 1024))
  throw new PayloadTooLargeException(`Images must be ${mb} MB or smaller.`)
}

That second check runs after the bytes are already in memory, so it is the belt to multer's braces — worth having, and no substitute. Checking buffer.length rather than the Content-Length header is the same principle as the rest of this lesson: the header is client-supplied, the buffer is what is about to be written.

files: 1 matters too. Without it, a hundred files of 5 MB each is a legal request.

Sniffing the bytes

The heart of it. Every real image format begins with a fixed byte sequence, and checking it takes twelve bytes:

const SIGNATURES: Signature[] = [
  { type: 'image/jpeg', extension: 'jpg', magic: [0xff, 0xd8, 0xff], offset: 0 },
  {
    type: 'image/png',
    extension: 'png',
    magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
    offset: 0,
  },
  {
    type: 'image/webp',
    extension: 'webp',
    magic: [0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50],
    offset: 0,
  },
]

WebP is a RIFF container — "RIFF", four bytes of length, then "WEBP" — which is why the signature has to allow gaps rather than being one contiguous run. null means "anything here".

export function sniffImageType(buffer: Buffer): SniffResult | null {
  for (const signature of SIGNATURES) {
    const end = signature.offset + signature.magic.length
    if (buffer.length < end) continue

    const matches = signature.magic.every((byte, index) => {
      if (byte === null) return true
      return buffer[signature.offset + index] === byte
    })

    if (matches) return { type: signature.type, extension: signature.extension }
  }
  return null
}

The length check before comparing is not defensive padding — a four-byte upload would otherwise read past the end of the buffer.

Compare the version this replaces:

// The wrong way. `mimetype` is the Content-Type the CLIENT wrote in the multipart
// part header. `curl -F "file=@shell.php;type=image/png"` sets it, and costs nothing.
if (!file.mimetype.startsWith('image/')) {
  throw new UnsupportedMediaTypeException('Upload an image.')
}

It is not that this is weak. It is that it checks a value the attacker chose.

Generate the filename

const filename = `${randomUUID()}.${sniffed.extension}`
const directory = resolve(process.cwd(), uploads.directory)
await mkdir(directory, { recursive: true })
await writeFile(join(directory, filename), file.buffer)

The extension comes from what the bytes turned out to be, not from what the client called the file. And the name is generated, so a file.originalname of ../../etc/passwd has nowhere to go — there is no path to traverse because no part of the path came from the request.

Sanitising originalname instead is the common approach and a worse one: it is a denylist, and denylists for path traversal have a long history of being incomplete. Generating the name is not a filter that could miss a case.

If the original name matters to users, store it in a column and serve it as a Content-Disposition header. The name on disk and the name a user sees do not have to be the same string.

Serving them back

const uploads = config.getOrThrow<AppConfig['uploads']>('uploads')
app.useStaticAssets(resolve(process.cwd(), uploads.directory), { prefix: '/uploads/' })

Served as static files from a directory that now contains nothing but generated filenames with known-good extensions. Express's static handler does not execute anything, so even a file that lied about its type is only ever sent, never run.

That is the third defence, and all three are needed. Sniffing keeps out what is not an image; generated names keep the path under control; static serving means execution is not on the table even if the first two were bypassed.

The database stores a relative path:

const url = `/uploads/${filename}`

Storing http://localhost:3001/uploads/… bakes today's host into every row, and the day this moves behind a domain, every image 404s.

Deleting, and which order

await this.dataSource.getRepository(PortfolioImage).remove(image)
// ...
if (filename) {
  await unlink(join(resolve(process.cwd(), uploads.directory), filename)).catch(() => {
    // Already gone, or never written. Either way the row is what mattered.
  })
}

Row first, file second, and a failed unlink does not fail the request.

The other order leaves a row pointing at a file that is gone, which renders as a broken image forever. This order leaves an unreferenced file on disk, which costs a few kilobytes and is invisible. When a cleanup has two failure modes, pick the one nobody has to look at.

Nest's built-in validators

There is a declarative option worth knowing:

@UploadedFile(
  new ParseFilePipe({
    validators: [
      new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }),
      new FileTypeValidator({ fileType: 'image/png' }),
    ],
  }),
)
file: Express.Multer.File

It is a pipe, so it fits the pipeline properly and a missing file is a clean 400 rather than a check you wrote. MaxFileSizeValidator is genuinely useful.

Read FileTypeValidator's documentation before relying on it, though — by default it matches against the client-supplied mime type, which is the value this whole lesson has been about not trusting. Recent versions can inspect the buffer instead. When in doubt, sniff.

Authorizing the upload

Easy to overlook while concentrating on the bytes: an upload endpoint is also a write endpoint, and it needs the same rules as any other.

@Controller('api/v1/contractors')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.CONTRACTOR)
export class ContractorsController {
  constructor(private readonly contractorsService: ContractorsService) {}
  // ...
}

Every route here is me, never :contractorId. The profile being edited is always the one in the caller's token, so there is no id to check ownership of and therefore no ownership check to forget — a POST /contractors/:id/portfolio would need that check on every handler, and the one that omits it is the bug.

Worth noting where the guards sit relative to the upload. Guards run before interceptors, so an unauthenticated request is rejected before multer reads a single byte. Had authentication been done inside the handler instead, every anonymous request would cost a full 5 MB read first.

Testing one

You do not need a real image. Supertest attaches a buffer, and a valid PNG header is eight bytes:

const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])

await http
  .post('/api/v1/contractors/me/portfolio')
  .set('Authorization', `Bearer ${luisToken}`)
  .attach('file', png, 'photo.png')
  .expect(201)

The interesting tests are the refusals, and they are cheap to write once you can hand over arbitrary bytes. A buffer of plain text named photo.png with a Content-Type of image/png must still be rejected — which is the only test that actually proves the sniffing is doing the work rather than the header.

Beyond images

Magic-byte sniffing works because image formats have fixed headers. Two categories do not fit that comfortably, and both are worth naming.

Documents that are archives. A .docx or .xlsx is a ZIP file, so its magic bytes say ZIP — which means sniffing tells you rather little, and the contents can include almost anything. Accepting them safely means never opening them in the application process.

SVG. It is XML, so there are no magic bytes at all, and it can contain script that runs when the file is viewed. An SVG served from your own domain is a stored XSS. Serve user-supplied SVG from a separate origin, or convert it to a raster format, or do not accept it.

Where the files should actually live

Writing to local disk is right for a demo and wrong for most deployments, and the reason is not capacity. A container's filesystem is ephemeral, so a redeploy loses every upload; and with more than one instance behind a load balancer, a file written by one is a 404 from the others.

The usual answer is object storage — S3 or equivalent — with the application storing a key rather than a path. Everything in this lesson still applies: the size cap, the sniffing and the generated name all happen before the upload, and "served in a way that cannot execute" becomes a property of the bucket.

What changes is that the bytes stop passing through your process at all, once you move to pre-signed URLs. That is a significant win and it moves the validation problem rather than solving it — a client uploading straight to a bucket is a client you have not checked, so the sniffing has to happen somewhere after the fact.

The four rules

Cap the size where the bytes are read, not after. Decide what the file is from its bytes, never from a header or an extension. Generate the stored filename rather than sanitising the supplied one. And serve the directory in a way that cannot execute anything.

Next: testing — including how to test an upload endpoint without a real image.