DEV Community

Tommaso Bertocchi
Tommaso Bertocchi

Posted on

How to Secure File Uploads in Next.js

File uploads are one of those features that look simple until you think about the security side.

A user uploads a file, your route accepts it, and everything seems fine.

But what if that file is not what it claims to be?

A renamed executable can look like a harmless PDF. A ZIP archive can hide traversal tricks or resource-exhaustion problems. A document can carry risky structures you would never catch by checking only the filename extension.

That is why upload endpoints are part of your attack surface.

In this article, I will show you how to secure file uploads in a Next.js App Router application by scanning them before storage with pompelmi, an open-source upload security tool for Node.js.

We will build a minimal example that:

  • accepts a file from a browser form
  • scans it on the server
  • returns a verdict
  • blocks suspicious or malicious uploads before they reach storage

Why file upload validation is usually too weak

A lot of upload handlers still rely on checks like these:

  • file.name.endsWith('.pdf')
  • file.type === 'application/pdf'
  • maximum size limits only

Those checks are still useful, but they are not enough.

The browser-provided MIME type can be wrong or misleading. The filename can be changed by the client. And some dangerous files look harmless until you inspect the actual bytes or the archive structure.

A safer model is:

  1. receive the upload
  2. inspect the file in-process
  3. decide whether to allow, quarantine, or reject it
  4. store only what passed the gate

That is exactly the role Pompelmi is designed for.


What we are going to build

We will use:

  • Next.js App Router
  • @pompelmi/next-upload for the upload route
  • pompelmi for the scanning logic and policy

At the end, you will have:

  • app/api/upload/route.ts
  • lib/security.ts
  • app/page.tsx

Install the packages

Inside your Next.js project, install the core package and the Next.js adapter:

npm install pompelmi @pompelmi/next-upload
Enter fullscreen mode Exit fullscreen mode

Pompelmi requires Node.js 18+, and for Next.js uploads you should use the Node runtime, not the Edge runtime.


Create the security config

Create lib/security.ts:

import {
  CommonHeuristicsScanner,
  composeScanners,
  createZipBombGuard,
} from 'pompelmi';

export const policy = {
  includeExtensions: ['pdf', 'png', 'jpg', 'jpeg', 'zip', 'txt'],
  allowedMimeTypes: [
    'application/pdf',
    'image/png',
    'image/jpeg',
    'application/zip',
    'text/plain',
  ],
  maxFileSizeBytes: 20 * 1024 * 1024,
  timeoutMs: 5000,
  concurrency: 4,
  failClosed: true,
};

export const scanner = composeScanners(
  [
    [
      'zipGuard',
      createZipBombGuard({
        maxEntries: 512,
        maxTotalUncompressedBytes: 100 * 1024 * 1024,
        maxCompressionRatio: 12,
      }),
    ],
    ['heuristics', CommonHeuristicsScanner],
    // ['yara', YourYaraScanner],
  ],
  {
    parallel: false,
    stopOn: 'suspicious',
    timeoutMsPerScanner: 1500,
    tagSourceName: true,
  }
);
Enter fullscreen mode Exit fullscreen mode

What this does

This setup gives you a practical upload gate:

  • includeExtensions limits which file extensions you accept
  • allowedMimeTypes defines which MIME types are allowed
  • maxFileSizeBytes blocks very large files early
  • failClosed: true makes the route safer if scanning fails or times out
  • createZipBombGuard(...) adds protection against hostile ZIP archives
  • CommonHeuristicsScanner adds built-in heuristic checks
  • composeScanners(...) lets you combine multiple scanners into one pipeline

You can keep this small at first and harden it later.


Create the upload route

Now create app/api/upload/route.ts:

import { createNextUploadHandler } from '@pompelmi/next-upload';
import { policy, scanner } from '@/lib/security';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

export const POST = createNextUploadHandler({
  ...policy,
  scanner,
});
Enter fullscreen mode Exit fullscreen mode

That is the core of the integration.

The important part is this line:

export const POST = createNextUploadHandler({
  ...policy,
  scanner,
});
Enter fullscreen mode Exit fullscreen mode

This creates a ready-to-use upload handler for your App Router route. It applies your policy and scanner before you decide to persist or process the file elsewhere.

Why runtime = 'nodejs' matters

The route must run in the Node.js runtime because file inspection is a server-side concern and the package is built for Node.js upload flows.


Create a minimal upload page

Now create app/page.tsx:

'use client';

import { useState } from 'react';

export default function HomePage() {
  const [result, setResult] = useState<any>(null);
  const [loading, setLoading] = useState(false);

  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();

    const form = event.currentTarget;
    const input = form.elements.namedItem('file') as HTMLInputElement;

    if (!input.files?.length) {
      return;
    }

    const formData = new FormData();
    formData.append('file', input.files[0]);

    setLoading(true);
    setResult(null);

    try {
      const response = await fetch('/api/upload', {
        method: 'POST',
        body: formData,
      });

      const data = await response.json();
      setResult({ status: response.status, data });
    } catch (error) {
      setResult({
        status: 500,
        data: { error: 'Request failed' },
      });
    } finally {
      setLoading(false);
    }
  }

  return (
    <main style={{ maxWidth: 720, margin: '40px auto', fontFamily: 'sans-serif' }}>
      <h1>Secure file upload demo</h1>
      <p>Upload a file and let the server inspect it before storage.</p>

      <form onSubmit={handleSubmit}>
        <input type="file" name="file" required />
        <button type="submit" disabled={loading} style={{ marginLeft: 12 }}>
          {loading ? 'Scanning...' : 'Upload'}
        </button>
      </form>

      {result && (
        <pre
          style={{
            marginTop: 24,
            padding: 16,
            border: '1px solid #ddd',
            borderRadius: 8,
            overflowX: 'auto',
          }}
        >
          {JSON.stringify(result, null, 2)}
        </pre>
      )}
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is intentionally simple.

It sends one file as multipart/form-data to /api/upload and prints the JSON response from the server.


What the response looks like

The exact shape can vary depending on your policy and scanner setup, but the important part is the verdict.

Typical verdicts are:

  • clean
  • suspicious
  • malicious

For example, a blocked upload might look conceptually like this:

{
  "ok": false,
  "error": "Upload blocked",
  "verdict": "suspicious",
  "reasons": [
    "MIME mismatch detected"
  ]
}
Enter fullscreen mode Exit fullscreen mode

A clean upload may return something like:

{
  "ok": true,
  "verdict": "clean"
}
Enter fullscreen mode Exit fullscreen mode

The key idea is that your application gets a policy decision at the upload boundary instead of blindly trusting the file.


Where to store the file

A common mistake is to store the file first and scan it later.

A safer pattern is:

  1. receive upload
  2. scan it immediately
  3. only then write it to disk, object storage, or downstream processing

That way, unsafe files do not quietly enter the rest of your system.

In real apps, you would typically:

  • scan in the route
  • store only if the verdict is clean
  • log blocked attempts
  • surface a generic error to the client

Hardening ideas for production

This example is intentionally minimal, but here are the next things I would tighten in production.

1. Narrow the allowlist

Do not accept more file types than you actually need.

If your product only needs PDFs and PNGs, do not allow ZIP, TXT, or JPEG just because it is convenient.

2. Keep file size limits strict

Large uploads increase cost and risk.

Set the smallest maxFileSizeBytes that still works for your business case.

3. Fail closed

If scanning times out or a scanner crashes, the safe default is to reject the upload.

That is why failClosed: true is such a useful default for public-facing uploads.

4. Add signature scanning later if needed

The example above uses ZIP guards and heuristics. If your threat model requires deeper detection, you can extend the scanner pipeline with your own YARA-based scanner.

5. Keep upload routes on the server only

Do not move this logic into the client. The browser can help with UX, but the security decision belongs to the server.


Why this approach fits Next.js well

One thing I like about this setup is that it matches the way modern Next.js apps are built.

You do not need a separate scanning service just to get started.

You define:

  • one server route
  • one policy object
  • one scanner pipeline

And you can iterate from there.

That makes it practical for:

  • internal tools
  • SaaS dashboards
  • file submission forms
  • CMS-like workflows
  • apps that accept PDFs, images, or ZIP uploads

Final thoughts

If your application accepts file uploads, security should not start after the file has already been stored.

It should start at the upload gate.

With Next.js App Router and Pompelmi, you can add that decision point with a small amount of code and a much safer default.

The idea is simple:

  • inspect first
  • store later

If you want, I can also write follow-up articles such as:

  • How to secure file uploads in NestJS
  • How to secure file uploads in Fastify
  • Magic bytes vs MIME type for file uploads
  • How to protect against ZIP bombs in Node.js

Full example recap

lib/security.ts

import {
  CommonHeuristicsScanner,
  composeScanners,
  createZipBombGuard,
} from 'pompelmi';

export const policy = {
  includeExtensions: ['pdf', 'png', 'jpg', 'jpeg', 'zip', 'txt'],
  allowedMimeTypes: [
    'application/pdf',
    'image/png',
    'image/jpeg',
    'application/zip',
    'text/plain',
  ],
  maxFileSizeBytes: 20 * 1024 * 1024,
  timeoutMs: 5000,
  concurrency: 4,
  failClosed: true,
};

export const scanner = composeScanners(
  [
    [
      'zipGuard',
      createZipBombGuard({
        maxEntries: 512,
        maxTotalUncompressedBytes: 100 * 1024 * 1024,
        maxCompressionRatio: 12,
      }),
    ],
    ['heuristics', CommonHeuristicsScanner],
  ],
  {
    parallel: false,
    stopOn: 'suspicious',
    timeoutMsPerScanner: 1500,
    tagSourceName: true,
  }
);
Enter fullscreen mode Exit fullscreen mode

app/api/upload/route.ts

import { createNextUploadHandler } from '@pompelmi/next-upload';
import { policy, scanner } from '@/lib/security';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

export const POST = createNextUploadHandler({
  ...policy,
  scanner,
});
Enter fullscreen mode Exit fullscreen mode

app/page.tsx

'use client';

import { useState } from 'react';

export default function HomePage() {
  const [result, setResult] = useState<any>(null);
  const [loading, setLoading] = useState(false);

  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();

    const form = event.currentTarget;
    const input = form.elements.namedItem('file') as HTMLInputElement;

    if (!input.files?.length) {
      return;
    }

    const formData = new FormData();
    formData.append('file', input.files[0]);

    setLoading(true);
    setResult(null);

    try {
      const response = await fetch('/api/upload', {
        method: 'POST',
        body: formData,
      });

      const data = await response.json();
      setResult({ status: response.status, data });
    } finally {
      setLoading(false);
    }
  }

  return (
    <main style={{ maxWidth: 720, margin: '40px auto', fontFamily: 'sans-serif' }}>
      <h1>Secure file upload demo</h1>
      <p>Upload a file and let the server inspect it before storage.</p>

      <form onSubmit={handleSubmit}>
        <input type="file" name="file" required />
        <button type="submit" disabled={loading} style={{ marginLeft: 12 }}>
          {loading ? 'Scanning...' : 'Upload'}
        </button>
      </form>

      {result && <pre>{JSON.stringify(result, null, 2)}</pre>}
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)