ScreenshotNeo

BlogHow-to

How to Secure Image Uploads in Next.js

Build a Next.js image upload flow that authenticates users, validates image bytes, limits resource use, and serves uploaded files safely.

By the ScreenshotNeo team4 October 202612 min read

Secure image uploads in Next.js by treating every upload as untrusted server input: authenticate and authorize the caller, enforce request and file-size limits, allowlist formats, inspect and decode the bytes, generate your own storage key, and control how files are served. The upload form and the browser-supplied filename, extension, and Content-Type do not establish trust.

This example uses a Route Handler and the Sharp image library to validate and normalize uploads to WebP. Adapt authentication, storage, accepted formats, limits, and CSRF protection to your application and hosting platform.

1. Choose an upload endpoint and set limits

A Server Action or Route Handler is a server endpoint. Protect it as you would any public-facing endpoint: authenticate and authorize every request, then validate all submitted fields and file bytes on the server. Next.js documents a default 1 MB request body limit for Server Actions. You can configure it with serverActions.bodySizeLimit; that setting is a request-body cap, not a recommended per-image limit. Multipart overhead, memory, image processing, and limits imposed by your host or proxy also matter.

A Route Handler gives you a clear place to stream or parse a request and connect it to your storage layer. A Server Action may suit a form-driven workflow, but its documented request-body cap can be a constraint. For a custom Route Handler, assess CSRF protections explicitly; do not assume Server Action protections apply to it. See the Next.js Server Actions configuration, authentication guide, and data security guide.

Configure a Server Action body cap if you use Server Actions

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    serverActions: {
      // Example only. Choose a cap for your own accepted files and deployment.
      bodySizeLimit: '5mb',
    },
  },
}

export default nextConfig

Next.js documents values such as numeric byte counts and strings such as '500kb' or '3mb'. Check the configuration supported by the Next.js version you deploy. Do not raise the framework limit without also considering host-level limits, memory, concurrency, and image-processing cost. If a proxy makes the visible host differ, Next.js provides serverActions.allowedOrigins for trusted additional origins; add only domains needed by your deployment.

2. Build a Route Handler that validates and normalizes uploads

Install Sharp with npm install sharp. The handler below illustrates the security checks: it requires a session, authorizes the operation, limits the bytes read, checks the actual decoded format, and writes normalized output under a random application-generated key. Replace the marked authentication, authorization, and storage functions with real implementations before using it.

// app/api/uploads/route.ts
import { randomUUID } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import sharp from 'sharp'
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { canUploadImages } from '@/lib/authorization'

export const runtime = 'nodejs'

const MAX_FILE_BYTES = 4 * 1024 * 1024
const MAX_PIXELS = 25_000_000
const STORAGE_DIR = path.join(process.cwd(), 'private-uploads')

const supportedFormats = new Set(['jpeg', 'png', 'webp'])

export async function POST(request: NextRequest) {
  const session = await getSession()
  if (!session?.user?.id) {
    return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
  }

  if (!(await canUploadImages(session.user.id))) {
    return NextResponse.json({ error: 'Upload not allowed' }, { status: 403 })
  }

  // Add and verify CSRF protection here if your authentication and deployment
  // allow cross-site requests. Also enforce appropriate request throttling.
  const form = await request.formData()
  const value = form.get('file')
  if (!(value instanceof File)) {
    return NextResponse.json({ error: 'Expected a file field named file' }, { status: 400 })
  }

  if (value.size === 0 || value.size > MAX_FILE_BYTES) {
    return NextResponse.json({ error: 'File is empty or exceeds the upload limit' }, { status: 413 })
  }

  // File.type and the filename are client-supplied hints. Do not trust them.
  const input = Buffer.from(await value.arrayBuffer())
  if (input.byteLength === 0 || input.byteLength > MAX_FILE_BYTES) {
    return NextResponse.json({ error: 'Invalid file size' }, { status: 413 })
  }

  try {
    const image = sharp(input, {
      limitInputPixels: MAX_PIXELS,
      // Do not enable broad format support unless the product needs it.
    })
    const metadata = await image.metadata()

    if (!metadata.format || !supportedFormats.has(metadata.format)) {
      return NextResponse.json({ error: 'Unsupported image format' }, { status: 415 })
    }
    if (!metadata.width || !metadata.height || metadata.width * metadata.height > MAX_PIXELS) {
      return NextResponse.json({ error: 'Image dimensions exceed the limit' }, { status: 413 })
    }

    // Decode and re-encode to a known raster format. This discards the original
    // filename and avoids serving the untrusted original bytes directly.
    const output = await image.rotate().webp({ quality: 82 }).toBuffer()
    const key = `${randomUUID()}.webp`

    // Example local storage only. In production, use private storage outside
    // the webroot or an object store with controlled retrieval policies.
    await mkdir(STORAGE_DIR, { recursive: true })
    await writeFile(path.join(STORAGE_DIR, key), output, { flag: 'wx', mode: 0o600 })

    return NextResponse.json({ key, contentType: 'image/webp' }, { status: 201 })
  } catch {
    return NextResponse.json({ error: 'File could not be decoded as a supported image' }, { status: 415 })
  }
}

The imports for getSession and canUploadImages are application-specific placeholders. The local filesystem example is not suitable for every deployment: ephemeral server filesystems may not persist, and a public webroot makes access control harder. Use private object storage or storage outside the webroot, then implement a controlled retrieval path.

Send a file from a browser form

// Example client component
'use client'

import { useState } from 'react'

export function ImageUploadForm() {
  const [message, setMessage] = useState('')

  async function submit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    const form = event.currentTarget
    const file = new FormData(form).get('file')
    if (!(file instanceof File)) return

    const body = new FormData()
    body.set('file', file)

    const response = await fetch('/api/uploads', {
      method: 'POST',
      body,
      credentials: 'same-origin',
      // Do not set Content-Type manually; fetch adds the multipart boundary.
    })
    const result = await response.json()
    setMessage(response.ok ? `Uploaded ${result.key}` : result.error)
  }

  return (
    <form onSubmit={submit}>
      <input name="file" type="file" accept="image/jpeg,image/png,image/webp" required />
      <button type="submit">Upload</button>
      <p role="status">{message}</p>
    </form>
  )
}

The accept attribute helps users choose a file, but it is only a user-interface hint. The server must independently enforce the same or stricter policy.

3. Validate file type in layers

  1. Allowlist only needed formats. For example, accept JPEG, PNG, and WebP if those meet the product requirement. Reject formats the feature does not need.
  2. Ignore client claims as proof. Extensions, filenames, and multipart Content-Type values are supplied by the client and can be spoofed.
  3. Parse and decode the bytes. Use a maintained image library to inspect the content and reject malformed or unsupported files. A signature check can add a layer, but it is not sufficient by itself.
  4. Normalize when appropriate. Decode and re-encode to an approved format. This can remove metadata and extraneous content that the chosen library does not preserve. Derive the output extension and response type from the normalized format.
  5. Keep parsers current and constrained. Parsing untrusted files is security-sensitive. Apply pixel/dimension limits and keep the image-processing dependency updated.

These practices follow the OWASP File Upload Cheat Sheet and OWASP Input Validation Cheat Sheet. Signature checks alone are not a substitute for parsing and validating the content.

4. Limit resource use and storage risk

  • Set multiple limits. Bound request body size, file size, image dimensions or pixel count, and processing time where the runtime permits. Account for multipart overhead and buffering in memory.
  • Apply quotas and rate controls. Per-user limits and request throttling reduce storage exhaustion and expensive processing abuse. Set values from product needs and deployment capacity; there is no universal safe limit.
  • Generate storage names. Use random or otherwise application-controlled keys. Never use a user-supplied filename as a filesystem path or object key.
  • Keep uploads separate from executable application content. Prefer private object storage, a separate host, or storage outside the webroot. Define explicit access and retention policies.
  • Consider scanning. Antivirus or sandbox scanning can add a layer when available and appropriate. It does not replace validation, safe storage, or access controls.

OWASP describes oversized files and unbounded storage as availability risks and recommends generated filenames and storage outside the webroot or on a separate host. For deployment-specific caps, check the limits of your hosting platform, proxy, and storage service as well as Next.js.

5. Serve uploads with deliberate headers and access rules

Uploaded files remain untrusted after storage. Set the response Content-Type from the server-validated output format, not the original request. Add X-Content-Type-Options: nosniff so browsers do not guess another type. Decide whether retrieval is public, authenticated, or time-limited, and enforce that policy on the retrieval path.

// next.config.ts: set a security header for routes serving uploaded content
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/media/:path*',
        headers: [
          { key: 'X-Content-Type-Options', value: 'nosniff' },
        ],
      },
    ]
  },
}

export default nextConfig

Configure the actual route or storage host that serves the files; a header on unrelated application pages does not protect uploaded content. See the Next.js headers configuration.

Should you allow SVG uploads?

Allow SVG only when the product needs it and you have a deliberate sanitization and serving policy. SVG can contain active content and shares features with HTML and CSS. Prefer excluding it from a raster-image upload path. If you intentionally serve SVG through Next.js image handling, the Next.js Image documentation recommends a restrictive Content Security Policy and contentDispositionType: 'attachment' when enabling dangerouslyAllowSVG. Do not assume that accepting an SVG as an image makes it safe.

6. Keep upload security separate from Next.js image optimization

The Next.js <Image> component helps optimize and display images. Its remotePatterns setting controls which remote sources the optimizer may fetch; it does not validate files uploaded by users. Use upload validation and storage controls at the server boundary, then configure image display and optimization for the trusted storage location. The Next.js Image documentation describes image serving configuration.

7. cURL, Python, and Node.js examples

These examples send a multipart file to the Route Handler above. They assume an authenticated session or other credentials accepted by your application. For cookie-based authentication, provide the session cookie securely; do not put secrets in source code or logs.

cURL

curl --fail-with-body \
  -X POST \
  -b 'session=YOUR_SESSION_COOKIE' \
  -F 'file=@./avatar.png;type=image/png' \
  https://your-app.example/api/uploads

Python

import requests

url = 'https://your-app.example/api/uploads'
with open('./avatar.png', 'rb') as image_file:
    response = requests.post(
        url,
        files={'file': ('avatar.png', image_file, 'image/png')},
        cookies={'session': 'YOUR_SESSION_COOKIE'},
        timeout=60,
    )
response.raise_for_status()
print(response.json())

Node.js

import { createReadStream } from 'node:fs'
import { FormData } from 'undici'
import { fileFrom } from 'undici'

const form = new FormData()
form.set('file', await fileFrom('./avatar.png', 'image/png'))

const response = await fetch('https://your-app.example/api/uploads', {
  method: 'POST',
  headers: { cookie: 'session=YOUR_SESSION_COOKIE' },
  body: form,
})

if (!response.ok) {
  throw new Error(`Upload failed: ${response.status} ${await response.text()}`)
}
console.log(await response.json())

For Node runtimes where fetch and compatible FormData are already available, use the runtime’s implementation. Do not set the multipart Content-Type header yourself; the client must add its boundary. Keep cookie values and bearer tokens out of checked-in code.

8. Common upload failures and fixes

Symptom Likely cause Fix
Request rejected before the handler runs Request body exceeds the Server Action, hosting platform, proxy, or server limit. Check each layer’s cap. Choose an appropriate limit and consider direct-to-object-storage uploads for large files.
Unsupported media type for a file that looks like an image The extension or claimed MIME type does not match supported decoded content, or the format is outside the allowlist. Inspect the server-side decoder’s detected format. Accept only formats the feature needs; do not trust the filename or multipart header.
Valid image rejected as too large Compressed byte size, decoded dimensions, or pixel count exceeds a configured limit. Report the applicable limit clearly. Review limits against real product requirements and processing capacity; do not remove pixel bounds blindly.
Browser request fails with a multipart parsing error Client code manually set Content-Type: multipart/form-data without the required boundary. Remove that header and let the browser or HTTP library construct it.
Upload returns 401 or 403 Missing/expired authentication or failed authorization. Send valid credentials, refresh the session as appropriate, and verify the server-side permission check.
Local upload works but production file disappears The deployment filesystem is ephemeral or not shared between instances. Use persistent private storage or an object store and keep retrieval policy explicit.
Image displays with the wrong type or is interpreted unexpectedly Retrieval uses a client-provided content type, serves raw uploads, or omits safe response headers. Serve normalized content with a server-derived type and X-Content-Type-Options: nosniff; isolate active formats.
Server runs out of memory or image conversion stalls Large files, large dimensions, concurrent processing, or full-buffer handling exceed runtime capacity. Lower byte and pixel caps, limit concurrency, use streaming or direct storage where suitable, and monitor processing resources.

9. Performance, reliability, and cost decisions

  • Buffering versus streaming: request.formData() plus arrayBuffer() is straightforward, but holds upload data in memory. For larger files or high concurrency, use a streaming parser or upload directly to private object storage with a short-lived authorized upload URL; validate the stored object before making it available.
  • Decode and re-encode: Normalization adds CPU time and may change image quality or metadata. Set explicit quality and dimensions for the product, and avoid processing files larger than necessary.
  • Dimension limits: A small compressed file can expand to a very large bitmap. Bound pixel count as well as bytes, and test your chosen limits against expected device images.
  • Retries: Make storage writes idempotent or create a new generated key per accepted attempt. Clean up temporary objects on validation failure and define retention behavior for abandoned uploads.
  • Deployment capacity: Framework, proxy, serverless runtime, and object-storage limits can differ. Verify all caps for the deployed configuration and account for simultaneous uploads.
  • Cost: Storage, bandwidth, image transformation, and scanning consume resources. Quotas, retention rules, format normalization, and resizing can control use; choose limits based on your actual service and hosting costs.

10. Security checklist

  • Authenticate and authorize every upload request on the server.
  • Validate every form field and file; treat browser checks as convenience only.
  • Set request, file-size, dimension, quota, and rate limits appropriate to the deployment.
  • Allowlist required formats, inspect and decode bytes, and consider re-encoding.
  • Generate storage keys; keep uploaded content outside executable application paths.
  • Make public versus private retrieval an explicit access-control decision.
  • Serve detected output types with nosniff; exclude SVG unless its handling is deliberate.
  • Review CSRF protections, storage cleanup, dependency updates, and host-level limits.

Or skip the browser setup

If you need screenshots of uploaded images or pages that show them, ScreenshotNeo is a website screenshot API and MCP server. Its screenshot endpoint does not secure or validate uploads; keep the server-side checks above. One GET request captures a URL. See the ScreenshotNeo API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

ScreenshotNeo accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits cost nothing, and response headers identify the page verdict and billing status. Its MCP server provides screenshot tools for Claude, Cursor, and other MCP clients. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 shots. Sign up for the free plan.

FAQ

What is the Server Action upload size limit?

Next.js documents a 1 MB default request body cap for Server Actions. It is configurable through serverActions.bodySizeLimit. The cap applies to the request body, so multipart overhead and deployment-level limits matter too.

Can I trust the file extension or MIME type?

No. Both are client-controlled claims. Compare them with server-side content detection and decoding, and use a narrow allowlist.

Does Next.js Image validate uploaded files?

No. The Image component and its remote source settings concern image optimization and serving, not upload validation.

Should uploaded images be public?

Only if the product requires public access. Otherwise keep objects private and enforce authorization on retrieval. Public files still need safe types, headers, and isolation.